Repository: Colin-b/healthpy

Issue:
Handle RFC draft v4 (2019-12-31)

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b534480..01ab22c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,9 +6,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+## [1.8.0] - 2020-02-16
+### Changed
+- Update to latest RFC specification (draft v4) meaning:
+    - affectedEndpoints is not sent anymore in case status is pass.
+    - output is not sent anymore in case status is pass.
+    - additional keys can now be provided.
+
+### Added
+- `healthpy.response_body` function to retrieve a dict to return as JSON to the client.
+- `healthpy.response_status_code` function to retrieve an HTTP status code to return to the client.
+- `healthpy.consul_response_status_code` function to retrieve an HTTP status code to return to Consul.
+
+### Fixed
+- affectedEndpoints is not sent anymore if not provided.
+
+### Deprecated
+- Providing non mandatory parameters via positional arguments will be removed in the next major release.
+
 ## [1.7.0] - 2019-11-29
 ### Added
 - Public release.
 
-[Unreleased]: https://github.com/Colin-b/healthpy/compare/v1.7.0...HEAD
+[Unreleased]: https://github.com/Colin-b/healthpy/compare/v1.8.0...HEAD
+[1.8.0]: https://github.com/Colin-b/healthpy/compare/v1.7.0...v1.8.0
 [1.7.0]: https://github.com/Colin-b/healthpy/releases/tag/v1.7.0
diff --git a/README.md b/README.md
index c997de3..593792d 100644
--- a/README.md
+++ b/README.md
@@ -1,37 +1,60 @@
-<h2 align="center">API Health Checks</h2>
+<h2 align="center">Health Check for HTTP APIs</h2>
 
 <p align="center">
 <a href="https://pypi.org/project/healthpy/"><img alt="pypi version" src="https://img.shields.io/pypi/v/healthpy"></a>
-<a href="https://travis-ci.org/Colin-b/healthpy"><img alt="Build status" src="https://api.travis-ci.org/Colin-b/healthpy.svg?branch=develop"></a>
-<a href="https://travis-ci.org/Colin-b/healthpy"><img alt="Coverage" src="https://img.shields.io/badge/coverage-100%25-brightgreen"></a>
+<a href="https://travis-ci.com/Colin-b/healthpy"><img alt="Build status" src="https://api.travis-ci.com/Colin-b/healthpy.svg?branch=develop"></a>
+<a href="https://travis-ci.com/Colin-b/healthpy"><img alt="Coverage" src="https://img.shields.io/badge/coverage-100%25-brightgreen"></a>
 <a href="https://github.com/psf/black"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a>
-<a href="https://travis-ci.org/Colin-b/healthpy"><img alt="Number of tests" src="https://img.shields.io/badge/tests-39 passed-blue"></a>
+<a href="https://travis-ci.com/Colin-b/healthpy"><img alt="Number of tests" src="https://img.shields.io/badge/tests-65 passed-blue"></a>
 <a href="https://pypi.org/project/healthpy/"><img alt="Number of downloads" src="https://img.shields.io/pypi/dm/healthpy"></a>
 </p>
 
-Health checks are based on [Health Check RFC](https://inadarei.github.io/rfc-healthcheck/) draft version 3.
+Create an health check endpoint on your REST API following [Health Check RFC](https://inadarei.github.io/rfc-healthcheck/) draft version 4.
 
-## HTTP
+- [Perform checks](#perform-checks)
+  - [Of an external HTTP resource](#http)
+  - [Of a redis server](#redis)
+- [Return health check result](#return-result)
+  - [Aggregate multiple statuses](#compute-status-from-multiple-statuses)
+  - [Use a custom status](#using-custom-status)
+  - [HTTP response body](#http-response-body)
+  - [HTTP response status code](#http-response-status-code)
 
-[requests](https://pypi.python.org/pypi/requests) module must be installed to perform HTTP health checks.
+## Perform checks
+
+In case you have external dependencies, you should check the health of those dependencies.
+
+### HTTP
+
+If you have an external HTTP resource, you can check its health,as in the following sample:
 
 ```python
 import healthpy.http
 
-status, details = healthpy.http.check("service name", "http://service_url")
+status, checks = healthpy.http.check("petstore", "https://petstore3.swagger.io/api/v3/openapi.json")
 ```
 
-## Redis
+Note: [requests](https://pypi.python.org/pypi/requests) module must be installed to perform HTTP health checks.
+
+### Redis
+
+If you rely on redis, you should check its health.
 
 [redis](https://pypi.python.org/pypi/redis) module must be installed to perform Redis health checks.
 
 ```python
 import healthpy.redis
 
-status, details = healthpy.redis.check("redis://redis_url", "redis_key")
+status, checks = healthpy.redis.check("redis://redis_url", "redis_key")
 ```
 
-## Compute status from multiple statuses
+## Return result
+
+Once all checks have been performed you should return the result to your client.
+
+### Compute status from multiple statuses
+
+If you performed more than one check, you have to compute an aggregated status from all the checks.
 
 ```python
 import healthpy
@@ -43,7 +66,7 @@ statusN = healthpy.fail_status
 status = healthpy.status(status1, status2, statusN)
 ```
 
-## Using custom status
+### Using custom status
 
 By default pass status is "pass", warn status is "warn" and fail status is "fail".
 
@@ -57,6 +80,53 @@ healthpy.warn_status = "custom"
 healthpy.fail_status = "error"
 ```
 
+### HTTP response body
+
+HTTP response body can be retrieved as a dictionary to be returned as JSON.
+
+```python
+import healthpy
+
+status = healthpy.pass_status  # replace with the aggregated status
+checks = {}  # replace with the computed checks
+
+body = healthpy.response_body(status, checks=checks)
+```
+
+Checks results are not mandatory in the response.
+
+```python
+import healthpy
+
+status = healthpy.pass_status  # replace with the aggregated status
+
+body = healthpy.response_body(status)
+```
+
+### HTTP response status code
+
+HTTP response status code can be retrieved as an integer.
+
+```python
+import healthpy
+
+status = healthpy.pass_status  # replace with the aggregated status
+
+status_code = healthpy.response_status_code(status)
+```
+
+#### Consul
+
+HTTP response status code should be a bit different for [Consul](https://www.consul.io/docs/agent/checks.html) health checks.
+
+```python
+import healthpy
+
+status = healthpy.pass_status  # replace with the aggregated status
+
+status_code = healthpy.consul_response_status_code(status)
+```
+
 ## Testing
 
 A `pytest` fixture can be used to mock the datetime returned in http health check.
@@ -65,6 +135,7 @@ A `pytest` fixture can be used to mock the datetime returned in http health chec
 from healthpy.testing import mock_http_health_datetime
 
 def test_http(mock_http_health_datetime):
+    # Time will be returned as "2018-10-11T15:05:05.663979"
     pass  # Add your test calling healthpy.http.check
 ```
 
diff --git a/healthpy/__init__.py b/healthpy/__init__.py
index 3f4f5a0..57118c0 100644
--- a/healthpy/__init__.py
+++ b/healthpy/__init__.py
@@ -1,6 +1,19 @@
 from healthpy._status import status
+from healthpy._response import (
+    response_body,
+    response_status_code,
+    consul_response_status_code,
+)
 from healthpy.version import __version__
 
+# API publishers SHOULD use following values for the field:
+# “pass”: healthy (acceptable aliases: “ok” to support Node’s Terminus and “up” for Java’s SpringBoot)
 pass_status = "pass"
+
+# API publishers SHOULD use following values for the field:
+# “warn”: healthy, with some concerns.
 warn_status = "warn"
+
+# API publishers SHOULD use following values for the field:
+# “fail”: unhealthy (acceptable aliases: “error” to support Node’s Terminus and “down” for Java’s SpringBoot)
 fail_status = "fail"
diff --git a/healthpy/_response.py b/healthpy/_response.py
new file mode 100644
index 0000000..aa8293f
--- /dev/null
+++ b/healthpy/_response.py
@@ -0,0 +1,81 @@
+import healthpy
+
+
+def response_body(status: str, **kwargs) -> dict:
+    """
+    Health Check Response Format for HTTP APIs uses the JSON format described in [RFC8259]
+    and has the media type “application/health+json”.
+
+    This function return a dict that can be returned as JSON.
+
+    :param status: (required) indicates whether the service status is acceptable or not.
+    The value of the status field is case-insensitive.
+    :param version: (optional) public version of the service. If not provided, version will be extracted from the
+    release_id, considering that release_id is following semantic versioning.
+    Version will be considered as the MAJOR component of a MAJOR.MINOR.PATCH release_id.
+    :param release_id: (optional) in well-designed APIs, backwards-compatible changes in the service
+    should not update a version number. APIs usually change their version number as infrequently as possible,
+    to preserve stable interface. However, implementation of an API may change much more frequently,
+    which leads to the importance of having separate “release number” or “releaseId”
+    that is different from the public version of the API.
+    :param notes: (optional) array of notes relevant to current state of health.
+    :param output: (optional) raw error output, in case of “fail” or “warn” states.
+    This field SHOULD be omitted for “pass” state.
+    :param checks: (optional) is an object that provides detailed health statuses of additional downstream systems
+    and endpoints which can affect the overall health of the main API.
+    :param links: (optional) is an object containing link relations and URIs [RFC3986]
+    for external links that MAY contain more information about the health of the endpoint.
+    All values of this object SHALL be URIs. Keys MAY also be URIs.
+    Per web-linking standards [RFC8288] a link relationship SHOULD either be a common/registered one
+    or be indicated as a URI, to avoid name clashes.
+    If a “self” link is provided, it MAY be used by clients to check health via HTTP response code, as mentioned above.
+    :param service_id: (optional) is a unique identifier of the service, in the application scope.
+    :param description: (optional) is a human-friendly description of the service.
+    """
+    body = {"status": status}
+
+    release_id = kwargs.pop("release_id", None)
+    if release_id:
+        kwargs["releaseId"] = release_id
+        kwargs["version"] = kwargs.pop("version", release_id.split(".", maxsplit=1)[0])
+
+    if "service_id" in kwargs:
+        kwargs["serviceId"] = kwargs.pop("service_id")
+
+    body.update(kwargs)
+    return body
+
+
+def response_status_code(status: str) -> int:
+    """
+    HTTP response code returned by the health endpoint.
+    For “pass” status, HTTP response code in the 2xx-3xx range MUST be used (200 will be used).
+    For “fail” status, HTTP response code in the 4xx-5xx range MUST be used (400 will be used).
+    In case of the “warn” status, endpoints MUST return HTTP status in the 2xx-3xx range (200 will be used),
+    and additional information SHOULD be provided, utilizing optional fields of the response.
+
+    :param status: Status of the application.
+    :return: HTTP status code to return to the client.
+    """
+    return 400 if healthpy.fail_status == status else 200
+
+
+def consul_response_status_code(status: str) -> int:
+    """
+    HTTP response code returned by the health endpoint.
+    That should be returned if the client is Consul.
+
+    More information on Consul health checks can be found here: https://www.consul.io/docs/agent/checks.html
+
+    For “pass” status, HTTP response code 200 is returned.
+    For “fail” status, HTTP response code 400 is returned.
+    In case of the “warn” status, HTTP response code 429 is returned.
+
+    :param status: Status of the application.
+    :return: HTTP status code to return to Consul.
+    """
+    if healthpy.fail_status == status:
+        return 400  # Consul consider every non 429 or 2** as Critical
+    if healthpy.warn_status == status:
+        return 429  # Consul consider a 429 as a Warning
+    return 200  # Consul consider every 2** as Ok
diff --git a/healthpy/http.py b/healthpy/http.py
index aef7a8b..225b6a9 100644
--- a/healthpy/http.py
+++ b/healthpy/http.py
@@ -1,12 +1,13 @@
 import datetime
 import re
+from typing import List
 
 import requests
 
 import healthpy
 
 
-def _api_health_status(health_response):
+def _api_health_status(health_response) -> str:
     if isinstance(health_response, dict):
         return health_response.get("status", healthpy.pass_status)
     return healthpy.pass_status
@@ -17,7 +18,8 @@ def check(
     url: str,
     status_extracting: callable = None,
     failure_status: str = None,
-    affected_endpoints=None,
+    affected_endpoints: List[str] = None,
+    additional_keys: dict = None,
     **requests_args,
 ) -> (str, dict):
     """
@@ -25,10 +27,11 @@ def check(
 
     :param service_name: External service name.
     :param url: External service health check URL.
-    :param status_extracting: Function returning status according to the JSON response (as parameter).
+    :param status_extracting: Function returning status according to the JSON or text response (as parameter).
     Default to the way status should be extracted from a service following healthcheck RFC.
     :param failure_status: Status to return in case of failure (Exception or HTTP rejection). healthpy.fail_status by default.
     :param affected_endpoints: List of endpoints affected if dependency is down. Default to None.
+    :param additional_keys: Additional user defined keys to send in checks.
     :return: A tuple with a string providing the status (amongst healthpy.*_status variable) and the "Checks object".
     Based on https://inadarei.github.io/rfc-healthcheck/
     """
@@ -47,40 +50,29 @@ def check(
                 )
                 else response.text
             )
-            return (
-                status_extracting(response),
-                {
-                    f"{service_name}:health": {
-                        "componentType": url,
-                        "observedValue": response,
-                        "status": status_extracting(response),
-                        "affectedEndpoints": affected_endpoints,
-                        "time": datetime.datetime.utcnow().isoformat(),
-                    }
-                },
-            )
-        return (
-            failure_status or healthpy.fail_status,
-            {
-                f"{service_name}:health": {
-                    "componentType": url,
-                    "status": failure_status or healthpy.fail_status,
-                    "affectedEndpoints": affected_endpoints,
-                    "time": datetime.datetime.utcnow().isoformat(),
-                    "output": response.text,
-                }
-            },
-        )
+            status = status_extracting(response)
+            check = {"observedValue": response}
+        else:
+            status = failure_status or healthpy.fail_status
+            check = {"output": response.text} if status != healthpy.pass_status else {}
     except Exception as e:
-        return (
-            failure_status or healthpy.fail_status,
-            {
-                f"{service_name}:health": {
-                    "componentType": url,
-                    "status": failure_status or healthpy.fail_status,
-                    "affectedEndpoints": affected_endpoints,
-                    "time": datetime.datetime.utcnow().isoformat(),
-                    "output": str(e),
-                }
-            },
-        )
+        status = failure_status or healthpy.fail_status
+        check = {"output": str(e)} if status != healthpy.pass_status else {}
+
+    if affected_endpoints and status != healthpy.pass_status:
+        check["affectedEndpoints"] = affected_endpoints
+
+    if additional_keys:
+        check.update(additional_keys)
+
+    return (
+        status,
+        {
+            f"{service_name}:health": {
+                "componentType": url,
+                "status": status,
+                "time": datetime.datetime.utcnow().isoformat(),
+                **check,
+            }
+        },
+    )
diff --git a/healthpy/redis.py b/healthpy/redis.py
index cd40302..29e23ca 100644
--- a/healthpy/redis.py
+++ b/healthpy/redis.py
@@ -5,15 +5,17 @@ import redis
 import healthpy
 
 
-def check(url: str, key_pattern: str) -> (str, dict):
+def check(url: str, key_pattern: str, additional_keys: dict = None) -> (str, dict):
     """
     Return Health "Checks object" for redis keys.
 
     :param url: Redis URL
     :param key_pattern: Pattern to look for in keys.
+    :param additional_keys: Additional user defined keys to send in checks.
     :return: A tuple with a string providing the status (amongst healthpy.*_status variable) and the "Checks object".
     Based on https://inadarei.github.io/rfc-healthcheck/
     """
+    additional_keys = additional_keys or {}
     try:
         redis_server = redis.Redis.from_url(url)
         redis_server.ping()
@@ -29,6 +31,7 @@ def check(url: str, key_pattern: str) -> (str, dict):
                         "status": healthpy.fail_status,
                         "time": datetime.utcnow().isoformat(),
                         "output": f"{key_pattern} cannot be found in {keys}",
+                        **additional_keys,
                     }
                 },
             )
@@ -41,6 +44,7 @@ def check(url: str, key_pattern: str) -> (str, dict):
                     "observedValue": f"{key_pattern} can be found.",
                     "status": healthpy.pass_status,
                     "time": datetime.utcnow().isoformat(),
+                    **additional_keys,
                 }
             },
         )
@@ -53,6 +57,7 @@ def check(url: str, key_pattern: str) -> (str, dict):
                     "status": healthpy.fail_status,
                     "time": datetime.utcnow().isoformat(),
                     "output": str(e),
+                    **additional_keys,
                 }
             },
         )
diff --git a/healthpy/version.py b/healthpy/version.py
index 9e2c214..d375a40 100644
--- a/healthpy/version.py
+++ b/healthpy/version.py
@@ -3,4 +3,4 @@
 # Major should be incremented in case there is a breaking change. (eg: 2.5.8 -> 3.0.0)
 # Minor should be incremented in case there is an enhancement. (eg: 2.5.8 -> 2.6.0)
 # Patch should be incremented in case there is a bug fix. (eg: 2.5.8 -> 2.5.9)
-__version__ = "1.7.0"
+__version__ = "1.8.0"
diff --git a/setup.py b/setup.py
index a580446..26f6019 100644
--- a/setup.py
+++ b/setup.py
@@ -13,7 +13,7 @@ setup(
     maintainer="Colin Bounouar",
     maintainer_email="colin.bounouar.dev@gmail.com",
     url="https://colin-b.github.io/healthpy/",
-    description="API Health Checks",
+    description="Health Check for HTTP APIs",
     long_description=long_description,
     long_description_content_type="text/markdown",
     download_url="https://pypi.org/project/healthpy/",


Test patch:
diff --git a/tests/test_http.py b/tests/test_http.py
index 7cdc7b2..0627f5a 100644
--- a/tests/test_http.py
+++ b/tests/test_http.py
@@ -13,13 +13,29 @@ def test_exception_health_check(mock_http_health_datetime):
                 "componentType": "http://test/health",
                 "output": "Connection refused by Responses: GET http://test/health doesn't match Responses Mock",
                 "status": "fail",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
     )
 
 
+def test_exception_health_check_additional_keys(mock_http_health_datetime):
+    assert healthpy.http.check(
+        "tests", "http://test/health", additional_keys={"custom": "test"}
+    ) == (
+        "fail",
+        {
+            "tests:health": {
+                "componentType": "http://test/health",
+                "output": "Connection refused by Responses: GET http://test/health doesn't match Responses Mock",
+                "status": "fail",
+                "time": "2018-10-11T15:05:05.663979",
+                "custom": "test",
+            }
+        },
+    )
+
+
 def test_exception_health_check_with_custom_status(
     monkeypatch, mock_http_health_datetime
 ):
@@ -31,7 +47,6 @@ def test_exception_health_check_with_custom_status(
                 "componentType": "http://test/health",
                 "output": "Connection refused by Responses: GET http://test/health doesn't match Responses Mock",
                 "status": "custom failure",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
@@ -39,14 +54,15 @@ def test_exception_health_check_with_custom_status(
 
 
 def test_exception_health_check_as_warn(mock_http_health_datetime):
-    assert healthpy.http.check("tests", "http://test/health", failure_status="warn") == (
+    assert healthpy.http.check(
+        "tests", "http://test/health", failure_status="warn"
+    ) == (
         "warn",
         {
             "tests:health": {
                 "componentType": "http://test/health",
                 "output": "Connection refused by Responses: GET http://test/health doesn't match Responses Mock",
                 "status": "warn",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
@@ -67,7 +83,6 @@ def test_exception_health_check_as_warn_even_with_custom_status(
                 "componentType": "http://test/health",
                 "output": "Connection refused by Responses: GET http://test/health doesn't match Responses Mock",
                 "status": "warn provided",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
@@ -88,13 +103,37 @@ def test_error_health_check(mock_http_health_datetime, responses: RequestsMock):
                 "componentType": "http://test/health",
                 "output": '{"message": "An error occurred"}',
                 "status": "fail",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
     )
 
 
+def test_error_health_check_additional_keys(
+    mock_http_health_datetime, responses: RequestsMock
+):
+    responses.add(
+        url="http://test/health",
+        method=responses.GET,
+        status=500,
+        json={"message": "An error occurred"},
+    )
+    assert healthpy.http.check(
+        "tests", "http://test/health", additional_keys={"custom": "test"}
+    ) == (
+        "fail",
+        {
+            "tests:health": {
+                "componentType": "http://test/health",
+                "output": '{"message": "An error occurred"}',
+                "status": "fail",
+                "time": "2018-10-11T15:05:05.663979",
+                "custom": "test",
+            }
+        },
+    )
+
+
 def test_error_health_check_as_warn(mock_http_health_datetime, responses: RequestsMock):
     responses.add(
         url="http://test/health",
@@ -102,14 +141,15 @@ def test_error_health_check_as_warn(mock_http_health_datetime, responses: Reques
         status=500,
         json={"message": "An error occurred"},
     )
-    assert healthpy.http.check("tests", "http://test/health", failure_status="warn") == (
+    assert healthpy.http.check(
+        "tests", "http://test/health", failure_status="warn"
+    ) == (
         "warn",
         {
             "tests:health": {
                 "componentType": "http://test/health",
                 "output": '{"message": "An error occurred"}',
                 "status": "warn",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
@@ -140,13 +180,47 @@ def test_pass_status_health_check(mock_http_health_datetime, responses: Requests
                     "version": "1",
                 },
                 "status": "pass",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
     )
 
 
+def test_pass_status_health_check_additional_keys(
+    mock_http_health_datetime, responses: RequestsMock
+):
+    responses.add(
+        url="http://test/health",
+        method=responses.GET,
+        status=200,
+        json={
+            "status": "pass",
+            "version": "1",
+            "releaseId": "1.2.3",
+            "details": {"toto": "tata"},
+        },
+    )
+    assert healthpy.http.check(
+        "tests", "http://test/health", additional_keys={"custom": "test"}
+    ) == (
+        "pass",
+        {
+            "tests:health": {
+                "componentType": "http://test/health",
+                "observedValue": {
+                    "details": {"toto": "tata"},
+                    "releaseId": "1.2.3",
+                    "status": "pass",
+                    "version": "1",
+                },
+                "status": "pass",
+                "time": "2018-10-11T15:05:05.663979",
+                "custom": "test",
+            }
+        },
+    )
+
+
 def test_pass_status_health_check_with_health_content_type(
     mock_http_health_datetime, responses: RequestsMock
 ):
@@ -176,7 +250,6 @@ def test_pass_status_health_check_with_health_content_type(
                     "version": "1",
                 },
                 "status": "pass",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
@@ -196,7 +269,6 @@ def test_pass_status_custom_health_check_pass(
                 "componentType": "http://test/status",
                 "observedValue": "pong",
                 "status": "pass",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
@@ -217,7 +289,6 @@ def test_pass_status_custom_health_check_with_custom_pass_status(
                 "componentType": "http://test/status",
                 "observedValue": "pong",
                 "status": "pass",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
@@ -237,7 +308,6 @@ def test_pass_status_custom_health_check_with_default_extractor(
                 "componentType": "http://test/status",
                 "observedValue": "pong",
                 "status": "pass",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
@@ -258,7 +328,6 @@ def test_pass_status_custom_health_check_with_default_extractor_and_custom_pass_
                 "componentType": "http://test/status",
                 "observedValue": "pong",
                 "status": "custom pass",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
@@ -289,13 +358,47 @@ def test_warn_status_health_check(mock_http_health_datetime, responses: Requests
                     "version": "1",
                 },
                 "status": "warn",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
     )
 
 
+def test_warn_status_health_check_additional_keys(
+    mock_http_health_datetime, responses: RequestsMock
+):
+    responses.add(
+        url="http://test/health",
+        method=responses.GET,
+        status=200,
+        json={
+            "status": "warn",
+            "version": "1",
+            "releaseId": "1.2.3",
+            "details": {"toto": "tata"},
+        },
+    )
+    assert healthpy.http.check(
+        "tests", "http://test/health", additional_keys={"custom": "test"}
+    ) == (
+        "warn",
+        {
+            "tests:health": {
+                "componentType": "http://test/health",
+                "observedValue": {
+                    "details": {"toto": "tata"},
+                    "releaseId": "1.2.3",
+                    "status": "warn",
+                    "version": "1",
+                },
+                "status": "warn",
+                "time": "2018-10-11T15:05:05.663979",
+                "custom": "test",
+            }
+        },
+    )
+
+
 def test_pass_status_custom_health_check_warn(
     mock_http_health_datetime, responses: RequestsMock
 ):
@@ -309,7 +412,6 @@ def test_pass_status_custom_health_check_warn(
                 "componentType": "http://test/status",
                 "observedValue": "pong",
                 "status": "warn",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
@@ -340,7 +442,6 @@ def test_fail_status_health_check(mock_http_health_datetime, responses: Requests
                     "version": "1",
                 },
                 "status": "fail",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
@@ -360,7 +461,6 @@ def test_fail_status_custom_health_check(
                 "componentType": "http://test/status",
                 "observedValue": "pong",
                 "status": "fail",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
@@ -375,7 +475,6 @@ def test_fail_status_when_server_is_down(mock_http_health_datetime):
                 "componentType": "http://test/status",
                 "output": "Connection refused by Responses: GET http://test/status doesn't match Responses Mock",
                 "status": "fail",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
@@ -383,14 +482,15 @@ def test_fail_status_when_server_is_down(mock_http_health_datetime):
 
 
 def test_fail_status_when_server_is_down_as_warn(mock_http_health_datetime):
-    assert healthpy.http.check("tests", "http://test/status", failure_status="warn") == (
+    assert healthpy.http.check(
+        "tests", "http://test/status", failure_status="warn"
+    ) == (
         "warn",
         {
             "tests:health": {
                 "componentType": "http://test/status",
                 "output": "Connection refused by Responses: GET http://test/status doesn't match Responses Mock",
                 "status": "warn",
-                "affectedEndpoints": None,
                 "time": "2018-10-11T15:05:05.663979",
             }
         },
diff --git a/tests/test_response.py b/tests/test_response.py
new file mode 100644
index 0000000..cbdd7a3
--- /dev/null
+++ b/tests/test_response.py
@@ -0,0 +1,126 @@
+import healthpy
+
+
+def test_default_pass_response_body():
+    assert healthpy.response_body(healthpy.pass_status) == {
+        "status": "pass",
+    }
+
+
+def test_default_warn_response_body():
+    assert healthpy.response_body(healthpy.warn_status) == {
+        "status": "warn",
+    }
+
+
+def test_default_fail_response_body():
+    assert healthpy.response_body(healthpy.fail_status) == {
+        "status": "fail",
+    }
+
+
+def test_response_body_version():
+    assert healthpy.response_body(healthpy.pass_status, version="1") == {
+        "status": "pass",
+        "version": "1",
+    }
+
+
+def test_response_body_release_id_non_semantic_without_version():
+    assert healthpy.response_body(healthpy.pass_status, release_id="1") == {
+        "status": "pass",
+        "releaseId": "1",
+        "version": "1",
+    }
+
+
+def test_response_body_release_id_non_semantic_with_version():
+    assert healthpy.response_body(
+        healthpy.pass_status, version="2", release_id="1"
+    ) == {"status": "pass", "releaseId": "1", "version": "2",}
+
+
+def test_response_body_release_id_semantic_without_version():
+    assert healthpy.response_body(healthpy.pass_status, release_id="1.2.3") == {
+        "status": "pass",
+        "releaseId": "1.2.3",
+        "version": "1",
+    }
+
+
+def test_response_body_release_id_semantic_with_version():
+    assert healthpy.response_body(
+        healthpy.pass_status, version="2", release_id="1.2.3"
+    ) == {"status": "pass", "releaseId": "1.2.3", "version": "2",}
+
+
+def test_response_body_notes():
+    assert healthpy.response_body(healthpy.pass_status, notes=["note 1", "note 2"]) == {
+        "status": "pass",
+        "notes": ["note 1", "note 2"],
+    }
+
+
+def test_response_body_output():
+    assert healthpy.response_body(healthpy.pass_status, output="test output") == {
+        "status": "pass",
+        "output": "test output",
+    }
+
+
+def test_response_body_checks():
+    assert healthpy.response_body(healthpy.pass_status, checks={}) == {
+        "status": "pass",
+        "checks": {},
+    }
+
+
+def test_response_body_links():
+    assert healthpy.response_body(
+        healthpy.pass_status, links={"http://key": "http://value"}
+    ) == {"status": "pass", "links": {"http://key": "http://value"},}
+
+
+def test_response_body_service_id():
+    assert healthpy.response_body(healthpy.pass_status, service_id="test") == {
+        "status": "pass",
+        "serviceId": "test",
+    }
+
+
+def test_response_body_description():
+    assert healthpy.response_body(
+        healthpy.pass_status, description="test description"
+    ) == {"status": "pass", "description": "test description",}
+
+
+def test_pass_response_status_code():
+    assert healthpy.response_status_code(healthpy.pass_status) == 200
+
+
+def test_warn_response_status_code():
+    assert healthpy.response_status_code(healthpy.warn_status) == 200
+
+
+def test_fail_response_status_code():
+    assert healthpy.response_status_code(healthpy.fail_status) == 400
+
+
+def test_unknown_response_status_code():
+    assert healthpy.response_status_code("unknown") == 200
+
+
+def test_pass_consul_response_status_code():
+    assert healthpy.consul_response_status_code(healthpy.pass_status) == 200
+
+
+def test_warn_consul_response_status_code():
+    assert healthpy.consul_response_status_code(healthpy.warn_status) == 429
+
+
+def test_fail_consul_response_status_code():
+    assert healthpy.consul_response_status_code(healthpy.fail_status) == 400
+
+
+def test_unknown_consul_response_status_code():
+    assert healthpy.consul_response_status_code("unknown") == 200