Skip to content

Commit 9857d9f

Browse files
author
Joshua Anderson
committed
fix(controller): convert config to correct types
config.cpu will always be a integer config.memory, config.values, config.tags will always be a string Previously the controller would take and save any type, provided it passed validation. This was problematic because it could lead to mixed or unexpected types. Examples: This config would be valid: ``` { "values": { "TEST1": true, "TEST2": 123, "TEST3": "test" } } ``` You could get a response like this: ``` { "cpu": { "web": 1024, "worker": "512" } } ```
1 parent db4efa7 commit 9857d9f

2 files changed

Lines changed: 84 additions & 11 deletions

File tree

controller/api/serializers.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,58 @@
2424

2525

2626
class JSONFieldSerializer(serializers.Field):
27+
"""
28+
A Django REST framework serializer for JSON data.
29+
"""
30+
2731
def to_representation(self, obj):
32+
"""Serialize the field's JSON data, for read operations."""
2833
return obj
2934

3035
def to_internal_value(self, data):
36+
"""Deserialize the field's JSON data, for write operations."""
3137
try:
3238
val = json.loads(data)
3339
except TypeError:
3440
val = data
3541
return val
3642

3743

44+
class JSONIntFieldSerializer(JSONFieldSerializer):
45+
"""
46+
A JSON serializer that coerces its data to integers.
47+
"""
48+
49+
def to_internal_value(self, data):
50+
"""Deserialize the field's JSON integer data."""
51+
field = super(JSONIntFieldSerializer, self).to_internal_value(data)
52+
53+
for k, v in field.viewitems():
54+
if v is not None: # NoneType is used to unset a value
55+
try:
56+
field[k] = int(v)
57+
except ValueError:
58+
field[k] = v
59+
# Do nothing, the validator will catch this later
60+
return field
61+
62+
63+
class JSONStringFieldSerializer(JSONFieldSerializer):
64+
"""
65+
A JSON serializer that coerces its data to strings.
66+
"""
67+
68+
def to_internal_value(self, data):
69+
"""Deserialize the field's JSON string data."""
70+
field = super(JSONStringFieldSerializer, self).to_internal_value(data)
71+
72+
for k, v in field.viewitems():
73+
if v is not None: # NoneType is used to unset a value
74+
field[k] = unicode(v)
75+
76+
return field
77+
78+
3879
class ModelSerializer(serializers.ModelSerializer):
3980

4081
uuid = serializers.ReadOnlyField()
@@ -129,10 +170,10 @@ class ConfigSerializer(ModelSerializer):
129170

130171
app = serializers.SlugRelatedField(slug_field='id', queryset=models.App.objects.all())
131172
owner = serializers.ReadOnlyField(source='owner.username')
132-
values = JSONFieldSerializer(required=False)
133-
memory = JSONFieldSerializer(required=False)
134-
cpu = JSONFieldSerializer(required=False)
135-
tags = JSONFieldSerializer(required=False)
173+
values = JSONStringFieldSerializer(required=False)
174+
memory = JSONStringFieldSerializer(required=False)
175+
cpu = JSONIntFieldSerializer(required=False)
176+
tags = JSONStringFieldSerializer(required=False)
136177
created = serializers.DateTimeField(format=settings.DEIS_DATETIME_FORMAT, read_only=True)
137178
updated = serializers.DateTimeField(format=settings.DEIS_DATETIME_FORMAT, read_only=True)
138179

controller/api/tests/test_config.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ def test_response_data(self):
114114
response = self.client.post('/v1/apps', json.dumps(body),
115115
content_type='application/json',
116116
HTTP_AUTHORIZATION='token {}'.format(self.token))
117-
url = "/v1/apps/test/config".format()
117+
url = "/v1/apps/test/config"
118118
# set an initial config value
119119
body = {'values': json.dumps({'PORT': '5000'})}
120120
response = self.client.post(url, json.dumps(body), content_type='application/json',
@@ -132,6 +132,38 @@ def test_response_data(self):
132132
}
133133
self.assertDictContainsSubset(expected, response.data)
134134

135+
@mock.patch('requests.post', mock_import_repository_task)
136+
def test_response_data_types_converted(self):
137+
"""Test that config data is converted into the correct type."""
138+
body = {'id': 'test'}
139+
response = self.client.post('/v1/apps', json.dumps(body),
140+
content_type='application/json',
141+
HTTP_AUTHORIZATION='token {}'.format(self.token))
142+
url = "/v1/apps/test/config"
143+
144+
body = {'values': json.dumps({'PORT': 5000}), 'cpu': json.dumps({'web': '1024'})}
145+
response = self.client.post(url, json.dumps(body), content_type='application/json',
146+
HTTP_AUTHORIZATION='token {}'.format(self.token))
147+
self.assertEqual(response.status_code, 201)
148+
for key in response.data:
149+
self.assertIn(key, ['uuid', 'owner', 'created', 'updated', 'app', 'values', 'memory',
150+
'cpu', 'tags'])
151+
expected = {
152+
'owner': self.user.username,
153+
'app': 'test',
154+
'values': {'PORT': '5000'},
155+
'memory': {},
156+
'cpu': {'web': 1024},
157+
'tags': {}
158+
}
159+
self.assertDictContainsSubset(expected, response.data)
160+
161+
body = {'cpu': json.dumps({'web': 'this will fail'})}
162+
response = self.client.post(url, json.dumps(body), content_type='application/json',
163+
HTTP_AUTHORIZATION='token {}'.format(self.token))
164+
self.assertEqual(response.status_code, 400)
165+
self.assertIn('CPU shares must be an integer', response.data['cpu'])
166+
135167
@mock.patch('requests.post', mock_import_repository_task)
136168
def test_config_set_same_key(self):
137169
"""
@@ -185,7 +217,7 @@ def test_config_set_unicode(self):
185217
HTTP_AUTHORIZATION='token {}'.format(self.token))
186218
self.assertEqual(response.status_code, 201)
187219
self.assertIn('INTEGER', response.data['values'])
188-
self.assertEqual(response.data['values']['INTEGER'], 1)
220+
self.assertEqual(response.data['values']['INTEGER'], '1')
189221

190222
@mock.patch('requests.post', mock_import_repository_task)
191223
def test_config_str(self):
@@ -333,7 +365,7 @@ def test_limit_cpu(self):
333365
self.assertIn('cpu', response.data)
334366
cpu = response.data['cpu']
335367
self.assertIn('web', cpu)
336-
self.assertEqual(cpu['web'], '1024')
368+
self.assertEqual(cpu['web'], 1024)
337369
# set an additional value
338370
body = {'cpu': json.dumps({'worker': '512'})}
339371
response = self.client.post(url, json.dumps(body), content_type='application/json',
@@ -343,19 +375,19 @@ def test_limit_cpu(self):
343375
self.assertNotEqual(limit1['uuid'], limit2['uuid'])
344376
cpu = response.data['cpu']
345377
self.assertIn('worker', cpu)
346-
self.assertEqual(cpu['worker'], '512')
378+
self.assertEqual(cpu['worker'], 512)
347379
self.assertIn('web', cpu)
348-
self.assertEqual(cpu['web'], '1024')
380+
self.assertEqual(cpu['web'], 1024)
349381
# read the limit again
350382
response = self.client.get(url, HTTP_AUTHORIZATION='token {}'.format(self.token))
351383
self.assertEqual(response.status_code, 200)
352384
limit3 = response.data
353385
self.assertEqual(limit2, limit3)
354386
cpu = response.data['cpu']
355387
self.assertIn('worker', cpu)
356-
self.assertEqual(cpu['worker'], '512')
388+
self.assertEqual(cpu['worker'], 512)
357389
self.assertIn('web', cpu)
358-
self.assertEqual(cpu['web'], '1024')
390+
self.assertEqual(cpu['web'], 1024)
359391
# unset a value
360392
body = {'memory': json.dumps({'worker': None})}
361393
response = self.client.post(url, json.dumps(body), content_type='application/json',

0 commit comments

Comments
 (0)