Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(342)

Side by Side Diff: third_party/google-endpoints/test/test_service.py

Issue 2666783008: Add google-endpoints to third_party/. (Closed)
Patch Set: Created 3 years, 10 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 # Copyright 2016 Google Inc. All Rights Reserved.
2 #
3 # Licensed under the Apache License, Version 2.0 (the "License");
4 # you may not use this file except in compliance with the License.
5 # You may obtain a copy of the License at
6 #
7 # http://www.apache.org/licenses/LICENSE-2.0
8 #
9 # Unless required by applicable law or agreed to in writing, software
10 # distributed under the License is distributed on an "AS IS" BASIS,
11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 # See the License for the specific language governing permissions and
13 # limitations under the License.
14
15 from __future__ import absolute_import
16
17 import datetime
18 import json
19 import os
20 import tempfile
21 import unittest2
22
23 from apitools.base.py import encoding
24 from expects import be_false, be_none, be_true, expect, equal, raise_error
25
26 from google.api.control import messages, service
27
28
29 _LOGGING_DESTINATIONS_INPUT = """
30 {
31 "logs": [{
32 "name": "endpoints-log",
33 "labels": [{
34 "key": "supported/endpoints-log-label"
35 }, {
36 "key": "unsupported/endpoints-log-label"
37 }]
38 }, {
39 "name": "unreferenced-log",
40 "labels": [{
41 "key": "supported/unreferenced-log-label"
42 }]
43 }],
44
45 "monitoredResources": [{
46 "type": "endpoints.googleapis.com/endpoints",
47 "labels": [{
48 "key": "unsupported/endpoints"
49 }, {
50 "key": "supported/endpoints"
51 }]
52 }],
53
54 "logging": {
55 "producerDestinations": [{
56 "monitoredResource": "bad-monitored-resource",
57 "logs": [
58 "bad-monitored-resource-log"
59 ]
60 }, {
61 "monitoredResource": "endpoints.googleapis.com/endpoints",
62 "logs": [
63 "bad-endpoints-log",
64 "endpoints-log"
65 ]
66 }]
67 }
68 }
69
70 """
71
72
73 class _JsonServiceBase(object):
74
75 def setUp(self):
76 self._subject = encoding.JsonToMessage(messages.Service, self._INPUT)
77
78 def _extract(self):
79 return service.extract_report_spec(
80 self._subject,
81 label_is_supported=fake_is_label_supported,
82 metric_is_supported=fake_is_metric_supported
83 )
84
85 def _get_registry(self):
86 return service.MethodRegistry(self._subject)
87
88
89 class TestLoggingDestinations(_JsonServiceBase, unittest2.TestCase):
90 _INPUT = _LOGGING_DESTINATIONS_INPUT
91 _WANTED_LABELS = [
92 'supported/endpoints-log-label',
93 'supported/endpoints'
94 ]
95
96 def test_should_access_the_valid_referenced_log(self):
97 logs, _metrics, _labels = self._extract()
98 expect(logs).to(equal(set(['endpoints-log'])))
99
100 def test_should_not_specify_any_metrics(self):
101 _logs, metrics, _labels = self._extract()
102 expect(metrics).to(equal([]))
103
104 def test_should_specify_the_labels_associated_with_the_valid_log(self):
105 _logs, _metrics, labels = self._extract()
106 expect(set(labels)).to(equal(set(self._WANTED_LABELS)))
107
108 def test_should_drop_conflicting_log_labels(self):
109 conflicting_label = messages.LabelDescriptor(
110 key='supported/endpoints-log-label',
111 valueType=messages.LabelDescriptor.ValueTypeValueValuesEnum.BOOL
112 )
113 bad_log_desc = messages.LogDescriptor(
114 name='bad-endpoints-log',
115 labels=[conflicting_label]
116 )
117 self._subject.logs.append(bad_log_desc)
118 _logs, _metrics, labels = self._extract()
119 expect(set(labels)).to(equal(set(self._WANTED_LABELS)))
120
121
122
123 _METRIC_DESTINATIONS_INPUTS = """
124 {
125 "metrics": [{
126 "name": "supported/endpoints-metric",
127 "labels": [{
128 "key": "supported/endpoints-metric-label"
129 }, {
130 "key": "unsupported/endpoints-metric-label"
131 }]
132 }, {
133 "name": "unsupported/unsupported-endpoints-metric",
134 "labels": [{
135 "key": "supported/unreferenced-metric-label"
136 }]
137 }, {
138 "name": "supported/non-existent-resource-metric",
139 "labels": [{
140 "key": "supported/non-existent-resource-metric-label"
141 }]
142 }],
143
144 "monitoredResources": {
145 "type": "endpoints.googleapis.com/endpoints",
146 "labels": [{
147 "key": "unsupported/endpoints"
148 }, {
149 "key": "supported/endpoints"
150 }]
151 },
152
153 "monitoring": {
154 "consumerDestinations": [{
155 "monitoredResource": "endpoints.googleapis.com/endpoints",
156 "metrics": [
157 "supported/endpoints-metric",
158 "unsupported/unsupported-endpoints-metric",
159 "supported/unknown-metric"
160 ]
161 }, {
162 "monitoredResource": "endpoints.googleapis.com/non-existent",
163 "metrics": [
164 "supported/endpoints-metric",
165 "unsupported/unsupported-endpoints-metric",
166 "supported/unknown-metric",
167 "supported/non-existent-resource-metric"
168 ]
169 }]
170 }
171 }
172
173 """
174
175 class TestMetricDestinations(_JsonServiceBase, unittest2.TestCase):
176 _INPUT = _METRIC_DESTINATIONS_INPUTS
177 _WANTED_METRICS = [
178 'supported/endpoints-metric'
179 ]
180 _WANTED_LABELS = [
181 'supported/endpoints-metric-label',
182 'supported/endpoints'
183 ]
184
185 def test_should_not_load_any_logs(self):
186 logs, _metrics, _labels = self._extract()
187 expect(logs).to(equal(set()))
188
189 def test_should_specify_some_metrics(self):
190 _logs, metrics, _labels = self._extract()
191 expect(metrics).to(equal(self._WANTED_METRICS))
192
193 def test_should_specify_the_labels_associated_with_the_metrics(self):
194 _logs, _metrics, labels = self._extract()
195 expect(set(labels)).to(equal(set(self._WANTED_LABELS)))
196
197
198 _NOT_SUPPORTED_PREFIX = 'unsupported/'
199
200
201 _COMBINED_LOG_METRIC_LABEL_INPUTS = """
202 {
203 "logs": {
204 "name": "endpoints-log",
205 "labels": [{
206 "key": "supported/endpoints-log-label"
207 }, {
208 "key": "unsupported/endpoints-log-label"
209 }]
210 },
211
212 "metrics": [{
213 "name": "supported/endpoints-metric",
214 "labels": [{
215 "key": "supported/endpoints-metric-label"
216 }, {
217 "key": "unsupported/endpoints-metric-label"
218 }]
219 }, {
220 "name": "supported/endpoints-consumer-metric",
221 "labels": [{
222 "key": "supported/endpoints-metric-label"
223 }, {
224 "key": "supported/endpoints-consumer-metric-label"
225 }]
226 }, {
227 "name": "supported/endpoints-producer-metric",
228 "labels": [{
229 "key": "supported/endpoints-metric-label"
230 }, {
231 "key": "supported/endpoints-producer-metric-label"
232 }]
233 }],
234
235 "monitoredResources": {
236 "type": "endpoints.googleapis.com/endpoints",
237 "labels": [{
238 "key": "unsupported/endpoints"
239 }, {
240 "key": "supported/endpoints"
241 }]
242 },
243
244 "logging": {
245 "producerDestinations": [{
246 "monitoredResource": "endpoints.googleapis.com/endpoints",
247 "logs": ["endpoints-log"]
248 }]
249 },
250
251 "monitoring": {
252 "consumerDestinations": [{
253 "monitoredResource": "endpoints.googleapis.com/endpoints",
254 "metrics": [
255 "supported/endpoints-consumer-metric",
256 "supported/endpoints-metric"
257 ]
258 }],
259
260 "producerDestinations": [{
261 "monitoredResource": "endpoints.googleapis.com/endpoints",
262 "metrics": [
263 "supported/endpoints-producer-metric",
264 "supported/endpoints-metric"
265 ]
266 }]
267 }
268 }
269
270 """
271
272 class TestCombinedExtraction(_JsonServiceBase, unittest2.TestCase):
273 _INPUT = _COMBINED_LOG_METRIC_LABEL_INPUTS
274 _WANTED_METRICS = [
275 "supported/endpoints-metric",
276 "supported/endpoints-consumer-metric",
277 "supported/endpoints-producer-metric"
278 ]
279 _WANTED_LABELS = [
280 "supported/endpoints", # from monitored resource
281 "supported/endpoints-log-label", # from log
282 "supported/endpoints-metric-label", # from both metrics
283 "supported/endpoints-consumer-metric-label", # from consumer metric
284 "supported/endpoints-producer-metric-label" # from producer metric
285 ]
286
287 def test_should_load_the_specified_logs(self):
288 logs, _metrics, _labels = self._extract()
289 expect(logs).to(equal(set(['endpoints-log'])))
290
291 def test_should_load_the_specified_metrics(self):
292 _logs, metrics, _labels = self._extract()
293 expect(set(metrics)).to(equal(set(self._WANTED_METRICS)))
294
295 def test_should_load_the_specified_metrics(self):
296 _logs, _metrics, labels = self._extract()
297 expect(set(labels)).to(equal(set(self._WANTED_LABELS)))
298
299
300 def fake_is_label_supported(label_desc):
301 return not label_desc.key.startswith(_NOT_SUPPORTED_PREFIX)
302
303
304 def fake_is_metric_supported(metric_desc):
305 return not metric_desc.name.startswith(_NOT_SUPPORTED_PREFIX)
306
307
308 _NO_NAME_SERVICE_CONFIG_TEST = """
309 {
310 "name": ""
311 }
312 """
313
314 class TestBadServiceConfig(_JsonServiceBase, unittest2.TestCase):
315 _INPUT = _NO_NAME_SERVICE_CONFIG_TEST
316
317 def test_should_fail_if_service_is_bad(self):
318 testfs = [
319 lambda: self._get_registry(),
320 lambda: service.MethodRegistry(None),
321 lambda: service.MethodRegistry(object()),
322 ]
323 for f in testfs:
324 expect(f).to(raise_error(ValueError))
325
326
327 _EMPTY_SERVICE_CONFIG_TEST = """
328 {
329 "name": "empty"
330 }
331 """
332
333 class TestEmptyServiceConfig(_JsonServiceBase, unittest2.TestCase):
334 _INPUT = _EMPTY_SERVICE_CONFIG_TEST
335
336 def test_should_obtain_a_registry(self):
337 expect(self._get_registry()).not_to(be_none)
338
339 def test_lookup_should_return_none(self):
340 test_verbs = ('GET', 'POST')
341 registry = self._get_registry()
342 for v in test_verbs:
343 info = registry.lookup('GET', 'any_url')
344 expect(info).to(be_none)
345
346
347 _BAD_HTTP_RULE_CONFIG_TEST = """
348 {
349 "name": "bad-http-rule",
350 "http": {
351 "rules": [{
352 "selector": "Uvw.BadRule.DoubleWildCard",
353 "get": "/uvw/not_present/**/**"
354 }, {
355 "selector": "Uvw.BadRule.NoMethod"
356 }, {
357 "get": "/uvv/bad_rule/no_selector"
358 },{
359 "selector": "Uvw.OkRule",
360 "get": "/uvw/ok_rule/*"
361 }]
362 }
363 }
364 """
365
366 class TestBadHttpRuleServiceConfig(_JsonServiceBase, unittest2.TestCase):
367 _INPUT = _BAD_HTTP_RULE_CONFIG_TEST
368
369 def test_lookup_should_return_none_for_unknown_uri(self):
370 test_verbs = ('GET', 'POST')
371 registry = self._get_registry()
372 for v in test_verbs:
373 expect(registry.lookup(v, 'uvw/unknown_url')).to(be_none)
374
375 def test_lookup_should_return_none_for_potential_bad_url_match(self):
376 registry = self._get_registry()
377 expect(registry.lookup('GET', '/uvw/not_present/is_bad')).to(be_none)
378 expect(registry.lookup('GET', '/uvw/bad_rule/no_selector')).to(be_none)
379 expect(registry.lookup('GET', 'uvw/not_present/is_bad')).to(be_none)
380 expect(registry.lookup('GET', 'uvw/not_present/is_bad')).to(be_none)
381
382
383 _USAGE_CONFIG_TEST = """
384 {
385 "name": "usage-config",
386 "usage": {
387 "rules": [{
388 "selector" : "Uvw.Method1",
389 "allowUnregisteredCalls" : true
390 }, {
391 "selector" : "Uvw.Method2",
392 "allowUnregisteredCalls" : false
393 }, {
394 "selector" : "Uvw.IgnoredMethod",
395 "allowUnregisteredCalls" : false
396 }]
397 },
398 "http": {
399 "rules": [{
400 "selector": "Uvw.Method1",
401 "get": "/uvw/method1/*"
402 }, {
403 "selector": "Uvw.Method2",
404 "get": "/uvw/method2/*"
405 }, {
406 "selector": "Uvw.DefaultUsage",
407 "get": "/uvw/default_usage"
408 }]
409 }
410 }
411 """
412
413 class TestMethodRegistryUsageConfig(_JsonServiceBase, unittest2.TestCase):
414 _INPUT = _USAGE_CONFIG_TEST
415
416 def test_should_detect_with_unregistered_calls_are_allowed(self):
417 registry = self._get_registry()
418 info = registry.lookup('GET', 'uvw/method1/abc')
419 expect(info).not_to(be_none)
420 expect(info.selector).to(equal('Uvw.Method1'))
421 expect(info.allow_unregistered_calls).to(be_true)
422
423 def test_should_detect_when_unregistered_calls_are_not_allowed(self):
424 registry = self._get_registry()
425 info = registry.lookup('GET', 'uvw/method2/abc')
426 expect(info).not_to(be_none)
427 expect(info.selector).to(equal('Uvw.Method2'))
428 expect(info.allow_unregistered_calls).to(be_false)
429
430 def test_should_default_to_disallowing_unregistered_calls(self):
431 registry = self._get_registry()
432 info = registry.lookup('GET', 'uvw/default_usage')
433 expect(info).not_to(be_none)
434 expect(info.selector).to(equal('Uvw.DefaultUsage'))
435 expect(info.allow_unregistered_calls).to(be_false)
436
437
438 _SYSTEM_PARAMETER_CONFIG_TEST = """
439 {
440 "name": "system-parameter-config",
441 "systemParameters": {
442 "rules": [{
443 "selector": "Uvw.Method1",
444 "parameters": [{
445 "name": "name1",
446 "httpHeader": "Header-Key1",
447 "urlQueryParameter": "param_key1"
448 }, {
449 "name": "name2",
450 "httpHeader": "Header-Key2",
451 "urlQueryParameter": "param_key2"
452 }, {
453 "name": "api_key",
454 "httpHeader": "ApiKeyHeader",
455 "urlQueryParameter": "ApiKeyParam"
456 }, {
457 "httpHeader": "Ignored-NoName-Key3",
458 "urlQueryParameter": "Ignored-NoName-key3"
459 }]
460 }, {
461 "selector": "Bad.NotConfigured",
462 "parameters": [{
463 "name": "neverUsed",
464 "httpHeader": "NeverUsed-Key1",
465 "urlQueryParameter": "NeverUsed_key1"
466 }]
467 }]
468 },
469 "http": {
470 "rules": [{
471 "selector": "Uvw.Method1",
472 "get": "/uvw/method1/*"
473 }, {
474 "selector": "Uvw.DefaultParameters",
475 "get": "/uvw/default_parameters"
476 }]
477 }
478 }
479 """
480
481
482 class TestMethodRegistrySystemParameterConfig(_JsonServiceBase, unittest2.TestCa se):
483 _INPUT = _SYSTEM_PARAMETER_CONFIG_TEST
484
485 def test_should_detect_registered_system_parameters(self):
486 registry = self._get_registry()
487 info = registry.lookup('GET', 'uvw/method1/abc')
488 expect(info).not_to(be_none)
489 expect(info.selector).to(equal('Uvw.Method1'))
490 expect(info.url_query_param('name1')).to(equal(('param_key1',)))
491 expect(info.url_query_param('name2')).to(equal(('param_key2',)))
492 expect(info.header_param('name1')).to(equal(('Header-Key1',)))
493 expect(info.header_param('name2')).to(equal(('Header-Key2',)))
494
495 def test_should_detect_default_api_key(self):
496 registry = self._get_registry()
497 info = registry.lookup('GET', 'uvw/method1/abc')
498 expect(info).not_to(be_none)
499 expect(info.api_key_http_header).to(equal(('ApiKeyHeader',)))
500 expect(info.api_key_url_query_params).to(equal(('ApiKeyParam',)))
501
502 def test_should_find_nothing_for_unregistered_params(self):
503 registry = self._get_registry()
504 info = registry.lookup('GET', 'uvw/method1/abc')
505 expect(info.url_query_param('name3')).to(equal(tuple()))
506 expect(info.header_param('name3')).to(equal(tuple()))
507
508 def test_should_detect_nothing_for_methods_with_no_registration(self):
509 registry = self._get_registry()
510 info = registry.lookup('GET', 'uvw/default_parameters')
511 expect(info).not_to(be_none)
512 expect(info.selector).to(equal('Uvw.DefaultParameters'))
513 expect(info.url_query_param('name1')).to(equal(tuple()))
514 expect(info.url_query_param('name2')).to(equal(tuple()))
515 expect(info.header_param('name1')).to(equal(tuple()))
516 expect(info.header_param('name2')).to(equal(tuple()))
517
518
519 _BOOKSTORE_CONFIG_TEST = """
520 {
521 "name": "bookstore-http-api",
522 "http": {
523 "rules": [{
524 "selector": "Bookstore.ListShelves",
525 "get": "/shelves"
526 }, {
527 "selector": "Bookstore.CorsShelves",
528 "custom": {
529 "kind": "OPTIONS",
530 "path": "shelves"
531 }
532 }, {
533 "selector": "Bookstore.ListBooks",
534 "get": "/shelves/{shelf=*}/books"
535 },{
536 "selector": "Bookstore.CreateBook",
537 "post": "/shelves/{shelf=*}/books",
538 "body": "book"
539 }]
540 }
541 }
542 """
543
544 class TestMethodRegistryBookstoreConfig(_JsonServiceBase, unittest2.TestCase):
545 _INPUT = _BOOKSTORE_CONFIG_TEST
546
547 def test_configures_list_shelves_ok(self):
548 registry = self._get_registry()
549 info = registry.lookup('GET', '/shelves')
550 expect(info).not_to(be_none)
551 expect(info.selector).to(equal('Bookstore.ListShelves'))
552 expect(info.body_field_path).to(equal(''))
553
554 def test_configures_options_shelves_ok(self):
555 registry = self._get_registry()
556 info = registry.lookup('OPTIONS', '/shelves')
557 expect(info).not_to(be_none)
558 expect(info.selector).to(equal('Bookstore.CorsShelves'))
559
560 def test_configures_list_books_ok(self):
561 registry = self._get_registry()
562 info = registry.lookup('GET', '/shelves/88/books')
563 expect(info).not_to(be_none)
564 expect(info.selector).to(equal('Bookstore.ListBooks'))
565 expect(info.body_field_path).to(equal(''))
566
567 def test_configures_create_book_ok(self):
568 registry = self._get_registry()
569 info = registry.lookup('POST', '/shelves/88/books')
570 expect(info).not_to(be_none)
571 expect(info.selector).to(equal('Bookstore.CreateBook'))
572 expect(info.body_field_path).to(equal('book'))
573
574
575 _OPTIONS_SELECTOR_CONFIG_TEST = """
576 {
577 "name": "options-selector",
578 "http": {
579 "rules": [{
580 "selector": "options-selector.OPTIONS",
581 "get": "/shelves"
582 }, {
583 "selector": "options-selector.OPTIONS.1",
584 "get": "/shelves/{shelf}"
585 }]
586 }
587 }
588 """
589
590 class TestOptionsSelectorConfig(_JsonServiceBase, unittest2.TestCase):
591 _INPUT = _OPTIONS_SELECTOR_CONFIG_TEST
592
593 def test_should_options_to_be_updated(self):
594 registry = self._get_registry()
595 info = registry.lookup('OPTIONS', '/shelves')
596 expect(info).not_to(be_none)
597 expect(info.selector).to(equal('options-selector.OPTIONS.2'))
598
599
600 class TestSimpleLoader(unittest2.TestCase):
601
602 def test_should_load_service_ok(self):
603 loaded = service.Loaders.SIMPLE.load()
604 registry = service.MethodRegistry(loaded)
605 info = registry.lookup('GET', '/anything')
606 expect(info).not_to(be_none)
607 info = registry.lookup('POST', '/anything')
608 expect(info).not_to(be_none)
609
610
611 class TestEnvironmentLoader(unittest2.TestCase):
612
613 def setUp(self):
614 _config_fd = tempfile.NamedTemporaryFile(delete=False)
615 with _config_fd as f:
616 f.write(_BOOKSTORE_CONFIG_TEST)
617 self._config_file = _config_fd.name
618 os.environ[service.CONFIG_VAR] = self._config_file
619
620 def tearDown(self):
621 if os.path.exists(self._config_file):
622 os.remove(self._config_file)
623
624 def test_does_not_load_if_env_is_not_set(self):
625 del os.environ[service.CONFIG_VAR]
626 loaded = service.Loaders.ENVIRONMENT.load()
627 expect(loaded).to(be_none)
628
629 def test_does_not_load_if_file_is_missing(self):
630 os.remove(self._config_file)
631 loaded = service.Loaders.ENVIRONMENT.load()
632 expect(loaded).to(be_none)
633
634 def test_does_not_load_if_config_is_bad(self):
635 with open(self._config_file, 'w') as f:
636 f.write('this is not json {')
637 loaded = service.Loaders.ENVIRONMENT.load()
638 expect(loaded).to(be_none)
639
640 def test_should_load_service_ok(self):
641 loaded = service.Loaders.ENVIRONMENT.load()
642 expect(loaded).not_to(be_none)
643 registry = service.MethodRegistry(loaded)
644 info = registry.lookup('GET', '/shelves')
645 expect(info).not_to(be_none)
646
647
648 _AUTHENTICATION_CONFIG_TEST = """
649 {
650 "name": "authentication-config",
651 "authentication": {
652 "rules": [{
653 "selector": "Bookstore.ListShelves",
654 "requirements": [{
655 "providerId": "shelves-provider",
656 "audiences": "aud1,aud2"
657 }]
658 }]
659 },
660 "http": {
661 "rules": [{
662 "selector": "Bookstore.ListShelves",
663 "get": "/shelves"
664 }, {
665 "selector": "Bookstore.CorsShelves",
666 "custom": {
667 "kind": "OPTIONS",
668 "path": "shelves"
669 }
670 }, {
671 "selector": "Bookstore.ListBooks",
672 "get": "/shelves/{shelf=*}/books"
673 },{
674 "selector": "Bookstore.CreateBook",
675 "post": "/shelves/{shelf=*}/books",
676 "body": "book"
677 }]
678 }
679 }
680 """
681
682 class TestAuthenticationConfig(_JsonServiceBase, unittest2.TestCase):
683 _INPUT = _AUTHENTICATION_CONFIG_TEST
684
685 def test_lookup_method_with_authentication(self):
686 registry = self._get_registry()
687 info = registry.lookup('GET', '/shelves')
688 auth_info = info.auth_info
689 self.assertIsNotNone(auth_info)
690 self.assertTrue(auth_info.is_provider_allowed("shelves-provider"))
691 self.assertFalse(auth_info.is_provider_allowed("random-provider"))
692 self.assertEqual(["aud1", "aud2"],
693 auth_info.get_allowed_audiences("shelves-provider"))
694 self.assertEqual([], auth_info.get_allowed_audiences("random-provider"))
695
696 def test_lookup_method_without_authentication(self):
697 registry = self._get_registry()
698 info = registry.lookup('OPTIONS', '/shelves')
699 self.assertIsNotNone(info)
700 self.assertIsNone(info.auth_info)
OLDNEW
« no previous file with comments | « third_party/google-endpoints/test/test_report_request.py ('k') | third_party/google-endpoints/test/test_service_config.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698