Repository: Dorthu/openapi3

Issue:
Bug causing "SpecError: Expected externalDocs to be of type ExternalDocumentation with required fields url"
Hello, after breaking my head for a couple of days trying to figure out why I was getting the aforementioned error saying that the `externalDocs` object could not be parsed.

Finally I found the problem and isolated the case being that the `can_parse` function for the `ExternalDocumentation` object  (which inherits `object_base`'s function) return false even though the object is correct.

Found it to be failing here:
https://github.com/Dorthu/openapi3/blob/7039a15e65477b505467b85bcd09215645f3ec9a/openapi3/object_base.py#L347-L349

This implies that the `url` field is not in the `__slots__` of the object.

Looking at the object (after way too many hours) I noticed that there's a typo:
https://github.com/Dorthu/openapi3/blob/34fb7a062eb5439f2b05b3d2185edb43f398574c/openapi3/general.py#L12-L13

It says `__slos__` instead of `__slots__` and also the `required_fields` needs to be a list.

Thinking that was it, I got another error (again I'm certain that the multiple JSONs that I'm testing on are correct):
```python
SpecError: Expected externalDocs to be one of [<class 'str'>], got <class 'dict'>
```
Which led me to another problem in the `tags` object:
https://github.com/Dorthu/openapi3/blob/34fb7a062eb5439f2b05b3d2185edb43f398574c/openapi3/tag.py#L20
The `externalDocs` object should be either a `dict` or `ExternalDocumentation`.

This also led me to a couple of inconsistencies in several other objects using which have the `externalDocs` field:

https://github.com/Dorthu/openapi3/blob/7039a15e65477b505467b85bcd09215645f3ec9a/openapi3/paths.py#L161
https://github.com/Dorthu/openapi3/blob/7039a15e65477b505467b85bcd09215645f3ec9a/openapi3/openapi.py#L175
https://github.com/Dorthu/openapi3/blob/7039a15e65477b505467b85bcd09215645f3ec9a/openapi3/schemas.py#L95

I'm not sure which one should be (most logical would be `ExternalDocumentation`), I'll be glad to make a pull request fixing it (I've already fixed it on my end and everything works) but I'm not sure which one is correct since leaving everything as is (except the `tag` object since that is almost certainly a bug) doesn't break anything even though I tried breaking it.

Once I get an answer I'll make the pull request.

Thanks.

Discussion:

Patch:
diff --git a/openapi3/general.py b/openapi3/general.py
index dd453a6..fbe2666 100644
--- a/openapi3/general.py
+++ b/openapi3/general.py
@@ -9,8 +9,8 @@ class ExternalDocumentation(ObjectBase):
     .. _External Documentation Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#externalDocumentationObject
     """
 
-    __slos__ = ["description", "url"]
-    required_fields = "url"
+    __slots__ = ["description", "url"]
+    required_fields = ["url"]
 
     def _parse_data(self):
         self.description = self._get("description", str)
diff --git a/openapi3/openapi.py b/openapi3/openapi.py
index 0b2c753..53e3e3a 100644
--- a/openapi3/openapi.py
+++ b/openapi3/openapi.py
@@ -172,7 +172,7 @@ class OpenAPI(ObjectBase):
         self._operation_map = {}
 
         self.components = self._get("components", ["Components"])
-        self.externalDocs = self._get("externalDocs", dict)
+        self.externalDocs = self._get("externalDocs", "ExternalDocumentation")
         self.info = self._get("info", "Info")
         self.openapi = self._get("openapi", str)
         self.paths = self._get("paths", ["Path"], is_map=True)
diff --git a/openapi3/schemas.py b/openapi3/schemas.py
index 1601407..d196471 100644
--- a/openapi3/schemas.py
+++ b/openapi3/schemas.py
@@ -92,7 +92,7 @@ class Schema(ObjectBase):
         self.readOnly = self._get("readOnly", bool)
         self.writeOnly = self._get("writeOnly", bool)
         self.xml = self._get("xml", dict)  # 'XML'
-        self.externalDocs = self._get("externalDocs", dict)  # 'ExternalDocs'
+        self.externalDocs = self._get("externalDocs", "ExternalDocumentation")  # 'ExternalDocs'
         self.deprecated = self._get("deprecated", bool)
         self.example = self._get("example", "*")
         self.contentEncoding = self._get("contentEncoding", str)
diff --git a/openapi3/tag.py b/openapi3/tag.py
index c333aaa..d55c82c 100644
--- a/openapi3/tag.py
+++ b/openapi3/tag.py
@@ -17,4 +17,4 @@ class Tag(ObjectBase):
         """
         self.name = self._get("name", str)
         self.description = self._get("description", str)
-        self.externalDocs = self._get("externalDocs", str)
+        self.externalDocs = self._get("externalDocs", "ExternalDocumentation")


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index e360518..bf3491b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -202,3 +202,10 @@ def empty_contact():
     Provides a spec with an empty contact section in info
     """
     yield _get_parsed_yaml("empty_contact.yaml")
+
+@pytest.fixture
+def with_external_docs():
+    """
+    Provides a spec with externalDocs objects in all valid places
+    """
+    yield _get_parsed_yaml("with-external-docs.yaml")
diff --git a/tests/fixtures/with-external-docs.yaml b/tests/fixtures/with-external-docs.yaml
new file mode 100644
index 0000000..1d7130f
--- /dev/null
+++ b/tests/fixtures/with-external-docs.yaml
@@ -0,0 +1,33 @@
+openapi: "3.1.0"
+info:
+  version: 1.0.0
+  title: With External Docs
+externalDocs:
+  url: http://example.org/openapi
+tags:
+- name: Example
+  description: Example
+  externalDocs:
+    url: http://example.org/tags
+paths:
+  /example:
+    get:
+      operationId: example
+      externalDocs:
+        description: Example external docs
+        url: http://example.org/operation
+      responses:
+        '200':
+          description: example
+          content:
+            application/json:
+              schema:
+                properties:
+                  name:
+                    type: string
+                    description: example
+                    example: something
+                externalDocs:
+                    url: http://example.org/schema
+                    description: External docs reference
+
diff --git a/tests/parsing_test.py b/tests/parsing_test.py
index eae6990..d20d6a1 100644
--- a/tests/parsing_test.py
+++ b/tests/parsing_test.py
@@ -148,3 +148,14 @@ def test_empty_contact(empty_contact):
     """
     spec = OpenAPI(empty_contact, validate=True)
     assert len(spec.errors()) == 0
+
+
+def test_external_docs(with_external_docs):
+    """
+    Tests that ExternalDocumentation objects are parsed as expected
+    """
+    spec = OpenAPI(with_external_docs)
+    assert spec.externalDocs.url == "http://example.org/openapi"
+    assert spec.tags[0].externalDocs.url == "http://example.org/tags"
+    assert spec.paths["/example"].get.externalDocs.url == "http://example.org/operation"
+    assert spec.paths["/example"].get.responses['200'].content['application/json'].schema.externalDocs.url == "http://example.org/schema"