Repository: StackStorm/st2

Issue:
Entry_point endpoint returns wrong content type
##### SUMMARY

`/action/views/entry_point` endpoint returns wrong content-type

##### ISSUE TYPE
 - Bug Report

##### STACKSTORM VERSION
root@c4651da9d7f6:/# st2 --version
st2 2.8.0, on Python 2.7.6

##### OS / ENVIRONMENT / INSTALL METHOD
st2-docker


##### STEPS TO REPRODUCE
```shell
root@c4651da9d7f6:/# curl -i https://localhost/api/v1/actions/views/entry_point/packs.install?x-auth-token="$(st2 auth -t st2admin)" --insecure
Password:
HTTP/1.1 200 OK
Server: nginx/1.15.1
Date: Wed, 25 Jul 2018 10:25:19 GMT
Content-Type: application/json
Content-Length: 892
Connection: keep-alive
Set-Cookie: auth-token=101c5e42dab546acae255e05277fd243; Max-Age=86399; Path=/; expires=Thu, 26-Jul-2018 10:25:18 GMT; HttpOnly
Access-Control-Allow-Origin: http://127.0.0.1:3000
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization,X-Auth-Token,St2-Api-Key,X-Request-ID
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: Content-Type,X-Limit,X-Total-Count,X-Request-ID
X-Request-ID: 8dc394fb-7753-4ffb-91aa-089a3abd0ee8
Front-End-Https: on
X-Content-Type-Options: nosniff

"---\n  chain:\n    -\n      name: \"download pack\"\n      ref: \"packs.download\"\n      parameters:\n        packs: \"{{packs}}\"\n        force: \"{{force}}\"\n      on-success: \"make a prerun\"\n    -\n      name: \"make a prerun\"\n      ref: \"packs.virtualenv_prerun\"\n      parameters:\n        packs_status: \"{{ __results['download pack'].result }}\"\n      on-success: \"install pack dependencies\"\n    -\n      name: \"install pack dependencies\"\n      ref: \"packs.setup_virtualenv
```

##### EXPECTED RESULTS
The endpoint should respect content MIME type and request `Accept` header. `Content-Type: application/json` should be `Content-Type: application/x-yaml`, `text/yaml` or `text/plain` depending on request's `Accept` header. See https://tools.ietf.org/html/rfc7231#section-5.3.2 for details.

##### ACTUAL RESULTS
The endpoint always return `Content-Type: application/json` no matter what MIME type the particular content has.


Discussion:

Resolving patch:
diff --git a/st2api/st2api/controllers/v1/action_views.py b/st2api/st2api/controllers/v1/action_views.py
--- a/st2api/st2api/controllers/v1/action_views.py
+++ b/st2api/st2api/controllers/v1/action_views.py
@@ -13,8 +13,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from mongoengine import ValidationError
+import os
+import codecs
+import mimetypes
+
 import six
+from mongoengine import ValidationError
 
 from st2api.controllers import resource
 from st2common.exceptions.db import StackStormDBObjectNotFoundError
@@ -27,6 +31,7 @@
 from st2common.rbac.types import PermissionType
 from st2common.rbac import utils as rbac_utils
 from st2common.router import abort
+from st2common.router import Response
 
 __all__ = [
     'OverviewController',
@@ -186,10 +191,32 @@ def get_one(self, ref_or_id, requester_user):
             raise StackStormDBObjectNotFoundError('Action ref_or_id=%s has no entry_point to output'
                                                   % ref_or_id)
 
-        with open(abs_path) as file:
-            content = file.read()
+        with codecs.open(abs_path, 'r') as fp:
+            content = fp.read()
 
-        return content
+        # Ensure content is utf-8
+        if isinstance(content, six.binary_type):
+            content = content.decode('utf-8')
+
+        try:
+            content_type = mimetypes.guess_type(abs_path)[0]
+        except Exception:
+            content_type = None
+
+        # Special case if /etc/mime.types doesn't contain entry for yaml, py
+        if not content_type:
+            _, extension = os.path.splitext(abs_path)
+            if extension in ['.yaml', '.yml']:
+                content_type = 'application/x-yaml'
+            elif extension in ['.py']:
+                content_type = 'application/x-python'
+            else:
+                content_type = 'text/plain'
+
+        response = Response()
+        response.headers['Content-Type'] = content_type
+        response.text = content
+        return response
 
 
 class ActionViewsController(object):
diff --git a/st2common/st2common/router.py b/st2common/st2common/router.py
--- a/st2common/st2common/router.py
+++ b/st2common/st2common/router.py
@@ -570,7 +570,12 @@ def __init__(self, **entries):
 
             try:
                 validator = CustomValidator(response_spec['schema'], resolver=self.spec_resolver)
-                validator.validate(resp.json)
+
+                response_type = response_spec['schema'].get('type', 'json')
+                if response_type == 'string':
+                    validator.validate(resp.text)
+                else:
+                    validator.validate(resp.json)
             except (jsonschema.ValidationError, ValueError):
                 LOG.exception('Response validation failed.')
                 resp.headers.add('Warning', '199 OpenAPI "Response validation failed"')


Test patch:
diff --git a/st2api/tests/unit/controllers/v1/test_action_views.py b/st2api/tests/unit/controllers/v1/test_action_views.py
--- a/st2api/tests/unit/controllers/v1/test_action_views.py
+++ b/st2api/tests/unit/controllers/v1/test_action_views.py
@@ -212,3 +212,51 @@ def test_get_one_ref(self):
             self.assertEqual(get_resp.status_int, 200)
         finally:
             self.app.delete('/v1/actions/%s' % action_id)
+
+    @mock.patch.object(action_validator, 'validate_action', mock.MagicMock(
+        return_value=True))
+    @mock.patch.object(content_utils, 'get_entry_point_abs_path', mock.MagicMock(
+        return_value='/path/to/file.yaml'))
+    @mock.patch(mock_open_name, mock.mock_open(read_data='file content'), create=True)
+    def test_get_one_ref_yaml_content_type(self):
+        post_resp = self.app.post_json('/v1/actions', ACTION_1)
+        action_id = post_resp.json['id']
+        action_ref = '.'.join((post_resp.json['pack'], post_resp.json['name']))
+        try:
+            get_resp = self.app.get('/v1/actions/views/entry_point/%s' % action_ref)
+            self.assertEqual(get_resp.status_int, 200)
+            self.assertEqual(get_resp.headers['Content-Type'], 'application/x-yaml')
+        finally:
+            self.app.delete('/v1/actions/%s' % action_id)
+
+    @mock.patch.object(action_validator, 'validate_action', mock.MagicMock(
+        return_value=True))
+    @mock.patch.object(content_utils, 'get_entry_point_abs_path', mock.MagicMock(
+        return_value=__file__.replace('.pyc', '.py')))
+    @mock.patch(mock_open_name, mock.mock_open(read_data='file content'), create=True)
+    def test_get_one_ref_python_content_type(self):
+        post_resp = self.app.post_json('/v1/actions', ACTION_1)
+        action_id = post_resp.json['id']
+        action_ref = '.'.join((post_resp.json['pack'], post_resp.json['name']))
+        try:
+            get_resp = self.app.get('/v1/actions/views/entry_point/%s' % action_ref)
+            self.assertEqual(get_resp.status_int, 200)
+            self.assertEqual(get_resp.headers['Content-Type'], 'application/x-python')
+        finally:
+            self.app.delete('/v1/actions/%s' % action_id)
+
+    @mock.patch.object(action_validator, 'validate_action', mock.MagicMock(
+        return_value=True))
+    @mock.patch.object(content_utils, 'get_entry_point_abs_path', mock.MagicMock(
+        return_value='/file/does/not/exist'))
+    @mock.patch(mock_open_name, mock.mock_open(read_data='file content'), create=True)
+    def test_get_one_ref_text_plain_content_type(self):
+        post_resp = self.app.post_json('/v1/actions', ACTION_1)
+        action_id = post_resp.json['id']
+        action_ref = '.'.join((post_resp.json['pack'], post_resp.json['name']))
+        try:
+            get_resp = self.app.get('/v1/actions/views/entry_point/%s' % action_ref)
+            self.assertEqual(get_resp.status_int, 200)
+            self.assertEqual(get_resp.headers['Content-Type'], 'text/plain')
+        finally:
+            self.app.delete('/v1/actions/%s' % action_id)