def test_backlinks_request_body_params(): doc_uri = fixture_uri("backlinks-request-body-params.yaml") apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri} expected_nodes = [ NodeKey(doc_uri, "/users", HttpMethod.POST), NodeKey(doc_uri, "/pets", HttpMethod.POST), ] expected_edges = [ ( expected_nodes[0], expected_nodes[1], (None, "201"), { "response_id": "201", "chain_id": None, "detail": LinkDetail( link_type=LinkType.BACKLINK, name="New User", description="", parameters={}, requestBody=None, requestBodyParameters={"/owner": "$response.body#/username"}, ), }, ), ] assert [node for node in apigraph.graph.nodes] == expected_nodes assert [ edge for edge in apigraph.graph.edges(data=True, keys=True) ] == expected_edges
def test_links_request_body(httpx_mock): doc_uri = fixture_uri("links-request-body.yaml") apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri} expected_nodes = [ NodeKey(doc_uri, "/users", HttpMethod.POST), NodeKey(doc_uri, "/pets/{id}/add-owner", HttpMethod.POST), ] expected_edges = [ ( expected_nodes[0], expected_nodes[1], (None, "201"), { "response_id": "201", "chain_id": None, "detail": LinkDetail( link_type=LinkType.LINK, name="Add Pet", description="", parameters={}, requestBody="$response.body", requestBodyParameters={}, ), }, ), ] assert [node for node in apigraph.graph.nodes] == expected_nodes assert [ edge for edge in apigraph.graph.edges(data=True, keys=True) ] == expected_edges
def test_cross_doc_links_circular_ref(httpx_mock): """ Test that we don't get stuck in infinite loop when resolving doc refs. NOTE: cross-doc-links.yaml and cross-doc-links-circular-ref.yaml together form a circular dependency in that both docs contain links to each other. This is via a mutual backlink and a link, since both edges are directed identically this should form a redundant edge. So we have a circular ref between docs, but not a circular dependency in the request graph. NOTE: since we are re-using cross-doc-links.yaml but with a different `fixture_uri` pre-parse substitution, this test relies on `clear_cache` auto fixture having `function` scope... """ doc_uri = "https://fakeurl/cross-doc-links.yaml" other_doc_uri = fixture_uri("cross-doc-circular-ref.yaml") raw_doc = str_doc_with_substitutions( "tests/fixtures/cross-doc-links.yaml", {"fixture_uri": other_doc_uri}, ) httpx_mock.add_response(url=doc_uri, data=raw_doc) apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri, other_doc_uri} expected_nodes = [ NodeKey(doc_uri, "/2.0/users", HttpMethod.POST), NodeKey(other_doc_uri, "/2.0/users/{username}", HttpMethod.GET), ] # the backlink+link edges in this case are redundant # we expect apigraph to take the backlink over the link expected_edges = [ ( expected_nodes[0], expected_nodes[1], (None, "201"), { "response_id": "201", "chain_id": None, "detail": LinkDetail( link_type=LinkType.BACKLINK, name="createUser", description="", parameters={}, requestBody=None, requestBodyParameters={"/username": "******"}, ), }, ), ] assert [node for node in apigraph.graph.nodes] == expected_nodes assert [ edge for edge in apigraph.graph.edges(data=True, keys=True) ] == expected_edges
def test_backlinks_via_components_ref(): """ We should be able to specify a backlink by using a $ref to refer to a shared backlink component. """ doc_uri = fixture_uri("backlinks-components-ref.yaml") apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri} expected_nodes = [ NodeKey(doc_uri, "/users", HttpMethod.POST), NodeKey(doc_uri, "/1.0/users/{username}", HttpMethod.GET), NodeKey(doc_uri, "/2.0/users/{username}", HttpMethod.GET), ] expected_edges = [ ( expected_nodes[0], expected_nodes[1], (None, "201"), { "response_id": "201", "chain_id": None, "detail": LinkDetail( link_type=LinkType.BACKLINK, name="CreateUser", description="Create a new user that matches the username of current request url segment", parameters={}, requestBody=None, requestBodyParameters={"/username": "******"}, ), }, ), ( expected_nodes[0], expected_nodes[2], (None, "201"), { "response_id": "201", "chain_id": None, "detail": LinkDetail( link_type=LinkType.BACKLINK, name="CreateUser", description="Create a new user that matches the username of current request url segment", parameters={}, requestBody=None, requestBodyParameters={"/username": "******"}, ), }, ), ] assert [node for node in apigraph.graph.nodes] == expected_nodes assert [ edge for edge in apigraph.graph.edges(data=True, keys=True) ] == expected_edges
def test_chain_for_node_with_cycle(): # TODO: # a circular dependency between endpoints... presumably it's possible # to annotate one but we should validate and reject this on some level # ...either when building graph? or when generating a request plan? # (redundant edges in same direction don't count) # ...if the cycle involves distinct chainIds should we ignore it? # ...what about a cycle of anonymous links? # Maybe this check should be in request-plan generation doc_uri = fixture_uri("links-with-cycle-in-chain.yaml") apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri} with pytest.raises(CircularDependencyError): apigraph.chain_for_node( node_key=NodeKey(doc_uri, "/2.0/repositories/{username}", "get"), chain_id=None, traverse_anonymous=False, )
def test_chain_for_node_anonymous_memoization(): """ Repeated calls to `chain_for_node` with/without `traverse_anonymous=True` should return different results (i.e. memoized independently) """ doc_uri = fixture_uri("dependencies.yaml") apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri} with_anon_deps = apigraph.chain_for_node( node_key=NodeKey(doc_uri, "/2.0/repositories/{username}", "get"), chain_id="default", traverse_anonymous=True, ) with_anon_expected_nodes = [ NodeKey(doc_uri, "/2.0/repositories/{username}", "get"), NodeKey(doc_uri, "/2.0/users", "post"), NodeKey(doc_uri, "/2.0/users/{username}", "get"), NodeKey( doc_uri, "/invite", "post", ), ] assert sorted([node for node in with_anon_deps.nodes ]) == with_anon_expected_nodes no_anon_deps = apigraph.chain_for_node( node_key=NodeKey(doc_uri, "/2.0/repositories/{username}", "get"), chain_id="default", traverse_anonymous=False, ) no_anon_expected_nodes = [ NodeKey(doc_uri, "/2.0/repositories/{username}", "get"), NodeKey(doc_uri, "/2.0/users", "post"), NodeKey(doc_uri, "/2.0/users/{username}", "get"), ] assert sorted([node for node in no_anon_deps.nodes]) == no_anon_expected_nodes
def test_links_multiple_chains(httpx_mock): doc_uri = fixture_uri("links-with-multiple-chain-id.yaml") apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri} # sorted expected_nodes = [ NodeKey(doc_uri, "/1.0/users/{username}", HttpMethod.GET), NodeKey(doc_uri, "/2.0/repositories/{username}", HttpMethod.GET), NodeKey(doc_uri, "/2.0/users/{username}", HttpMethod.GET), ] expected_edges = [ ( expected_nodes[0], expected_nodes[1], ("v1", "200"), { "response_id": "200", "chain_id": "v1", "detail": LinkDetail( link_type=LinkType.LINK, name="userRepositories", description="Get list of repositories", parameters={"username": "******"}, requestBody=None, requestBodyParameters={}, ), }, ), ( expected_nodes[2], expected_nodes[1], ("default", "200"), { "response_id": "200", "chain_id": "default", "detail": LinkDetail( link_type=LinkType.LINK, name="userRepositories", description="Get list of repositories", parameters={"username": "******"}, requestBody=None, requestBodyParameters={}, ), }, ), ] assert sorted([node for node in apigraph.graph.nodes]) == expected_nodes assert [ edge for edge in apigraph.graph.edges(data=True, keys=True) ] == expected_edges
def test_links(fixture, chain_id): """ NOTE: these fixtures use within-doc $refs, so that is tested too links.yaml and links-with-chain-id.yaml use `operationId` while links-local-operationref.yaml uses a relative (local) `operationRef` NOTE: these links all use `parameters` and not `requestBody` or `x-apigraph-requestBodyParameters` """ doc_uri = fixture_uri(fixture) apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri} expected_nodes = [ NodeKey(doc_uri, "/2.0/users/{username}", HttpMethod.GET), NodeKey(doc_uri, "/2.0/repositories/{username}", HttpMethod.GET), ] expected_edges = [ ( expected_nodes[0], expected_nodes[1], (chain_id, "200"), { "response_id": "200", "chain_id": chain_id, "detail": LinkDetail( link_type=LinkType.LINK, name="userRepositories", description="Get list of repositories", parameters={"username": "******"}, requestBody=None, requestBodyParameters={}, ), }, ), ] assert [node for node in apigraph.graph.nodes] == expected_nodes assert [ edge for edge in apigraph.graph.edges(data=True, keys=True) ] == expected_edges
def test_link_backlink_same_chain_consolidation(): """ in case of redundant edge key defined as both link and backlink we should store a single edge with detail from the backlink (we expect backlinks to be more explicit as they are an apigraph extension to OpenAPI) """ doc_uri = fixture_uri("backlinks-with-links-same-chain-id.yaml") apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri} expected_nodes = [ NodeKey(doc_uri, "/2.0/users/{username}", HttpMethod.GET), NodeKey(doc_uri, "/2.0/repositories/{username}", HttpMethod.GET), ] expected_edges = [ ( expected_nodes[0], expected_nodes[1], ("default", "200"), { "response_id": "200", "chain_id": "default", "detail": LinkDetail( link_type=LinkType.BACKLINK, name="Get User by Username", description="", parameters={"username": "******"}, requestBody=None, requestBodyParameters={}, ), }, ), ] assert [node for node in apigraph.graph.nodes] == expected_nodes assert [ edge for edge in apigraph.graph.edges(data=True, keys=True) ] == expected_edges
def test_backlinks(fixture, chain_id): """ NOTE: these links all use `parameters` and not `requestBody` or `x-apigraph-requestBodyParameters` """ doc_uri = fixture_uri(fixture) apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri} expected_nodes = [ NodeKey(doc_uri, "/2.0/users/{username}", HttpMethod.GET), NodeKey(doc_uri, "/2.0/repositories/{username}", HttpMethod.GET), ] expected_edges = [ ( expected_nodes[0], expected_nodes[1], (chain_id, "200"), { "response_id": "200", "chain_id": chain_id, "detail": LinkDetail( link_type=LinkType.BACKLINK, name="Get User by Username", description="", parameters={"username": "******"}, requestBody=None, requestBodyParameters={}, ), }, ), ] assert [node for node in apigraph.graph.nodes] == expected_nodes assert [ edge for edge in apigraph.graph.edges(data=True, keys=True) ] == expected_edges
def test_chain_for_node(traverse_anonymous): """ The test fixture contains three dependency chains, two of which end at `createUser` and `createUserv1` respectively and both beginning at `getRepository`. The third chain is the 'anonymous' link (no chainId specified) which extends the "default" chain to begin at /invite. We request dependencies of `getRepositoriesByOwner`, which is not at the end of either chain (is followed by `getRepository`) and check that we only select up to the requested node and no further. """ doc_uri = fixture_uri("dependencies.yaml") apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri} default_deps = apigraph.chain_for_node( node_key=NodeKey(doc_uri, "/2.0/repositories/{username}", "get"), chain_id="default", traverse_anonymous=traverse_anonymous, ) v1_deps = apigraph.chain_for_node( node_key=NodeKey(doc_uri, "/2.0/repositories/{username}", "get"), chain_id="v1", traverse_anonymous=traverse_anonymous, ) # dependencies from the "default" chain # NOTE: subsequent op `/2.0/repositories/{username}/{slug}` is not included # (nodes here manually sorted in url order for test case) default_expected_nodes = [ NodeKey(doc_uri, "/2.0/repositories/{username}", "get"), NodeKey(doc_uri, "/2.0/users", "post"), NodeKey(doc_uri, "/2.0/users/{username}", "get"), ] # the "invite" predecessor has no chainId and is only included when # `traverse_anonymous=True` if traverse_anonymous: default_expected_nodes.append(NodeKey( doc_uri, "/invite", "post", )) assert sorted([node for node in default_deps.nodes]) == default_expected_nodes # (edges here manually sorted in from-node??? order for test case) default_expected_edges = [ ( default_expected_nodes[1], default_expected_nodes[2], ("default", "201"), { "response_id": "201", "chain_id": "default", "detail": LinkDetail( link_type=LinkType.LINK, name="userByUsername", description="", parameters={"username": "******"}, requestBody=None, requestBodyParameters={}, ), }, ), ( default_expected_nodes[2], default_expected_nodes[0], ("default", "200"), { "response_id": "200", "chain_id": "default", "detail": LinkDetail( link_type=LinkType.LINK, name="userRepositories", description="Get list of repositories", parameters={"username": "******"}, requestBody=None, requestBodyParameters={}, ), }, ), ] # the "invite" predecessor has no chainId and is only included when # `traverse_anonymous=True` if traverse_anonymous: default_expected_edges.append(( default_expected_nodes[3], default_expected_nodes[1], (None, "201"), { "response_id": "201", "chain_id": None, "detail": LinkDetail( link_type=LinkType.BACKLINK, name="Redeem Invite", description="Create a user by redeeming an invite id+token", parameters={"invite-id": "$response.body#/id"}, requestBody=None, requestBodyParameters={ "/invite-token": "$response.body#/token" }, ), }, ), ) assert (sorted([edge for edge in default_deps.edges(data=True, keys=True) ]) == default_expected_edges) # dependencies from the "v1" chain # (this chain is not extended by any anonymous links in the document) v1_expected_nodes = [ NodeKey(doc_uri, "/1.0/users", "post"), NodeKey(doc_uri, "/1.0/users/{username}", "get"), NodeKey(doc_uri, "/2.0/repositories/{username}", "get"), ] v1_expected_edges = [ ( v1_expected_nodes[0], v1_expected_nodes[1], ("v1", "201"), { "response_id": "201", "chain_id": "v1", "detail": LinkDetail( link_type=LinkType.LINK, name="userByUsername", description="", parameters={"username": "******"}, requestBody=None, requestBodyParameters={}, ), }, ), ( v1_expected_nodes[1], v1_expected_nodes[2], ("v1", "200"), { "response_id": "200", "chain_id": "v1", "detail": LinkDetail( link_type=LinkType.LINK, name="userRepositories", description="Get list of repositories", parameters={"username": "******"}, requestBody=None, requestBodyParameters={}, ), }, ), ] assert sorted([node for node in v1_deps.nodes]) == v1_expected_nodes assert (sorted([edge for edge in v1_deps.edges(data=True, keys=True) ]) == v1_expected_edges)
def test_parameter_merging(): """ OpenAPI allows to specify parameters at the PathItem level which apply to all Operations under that path. Operation parameters can override a PathItem param of the same name, but empty param list of operation does not remove path params. Use of a list rather than map structure means the yaml could contain duplicate params in either location with no override intended. "A unique parameter is defined by a combination of a name and location." OpenAPI docs say: > The list MUST NOT include duplicated parameters. ...so we can expect this to be validated at the model level. """ doc_uri = fixture_uri("parameters.yaml") apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri} # sorted (abitrarily in path, method order for sake of test) expected_nodes = [ ( NodeKey(doc_uri, "/2.0/users/{username}", HttpMethod.DELETE), { "detail": OperationDetail( path="/2.0/users/{username}", method=HttpMethod.DELETE, summary="", description="", parameters={ ParamKey("username", In.PATH): Parameter( **{ "name": "username", "in": In.PATH, "required": True, "schema": {"type": "string"}, } ), # additional param of same name, different `in` location ParamKey("username", In.QUERY): Parameter( **{ "name": "username", "in": In.QUERY, "required": False, "schema": {"type": "string"}, } ), ParamKey("api-token", In.QUERY): Parameter( **{ "name": "api-token", "in": In.QUERY, "required": True, # overridden to true "schema": {"type": "string"}, } ), }, requestBody=None, security_schemes=set(), ) }, ), ( NodeKey(doc_uri, "/2.0/users/{username}", HttpMethod.GET), { "detail": OperationDetail( path="/2.0/users/{username}", method=HttpMethod.GET, summary="", description="", parameters={ # only has params inherited from PathItem ParamKey("username", In.PATH): Parameter( **{ "name": "username", "in": In.PATH, "required": True, "schema": {"type": "string"}, } ), ParamKey("api-token", In.QUERY): Parameter( **{ "name": "api-token", "in": In.QUERY, "required": False, "schema": {"type": "string"}, } ), }, requestBody=None, security_schemes=set(), ) }, ), ] assert sorted([node for node in apigraph.graph.nodes(data=True)]) == expected_nodes
def test_cross_doc_links(httpx_mock): """ NOTE: cross-doc-links.yaml links via `operationRef` URI to links.yaml So between this and `test_links` both `operationRef` and `operationId` links are tested """ doc_uri = "https://fakeurl/cross-doc-links.yaml" other_doc_uri = fixture_uri("links.yaml") raw_doc = str_doc_with_substitutions( "tests/fixtures/cross-doc-links.yaml", {"fixture_uri": other_doc_uri}, ) httpx_mock.add_response(url=doc_uri, data=raw_doc) apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri, other_doc_uri} expected_nodes = [ NodeKey(doc_uri, "/2.0/users", HttpMethod.POST), NodeKey(other_doc_uri, "/2.0/users/{username}", HttpMethod.GET), NodeKey(other_doc_uri, "/2.0/repositories/{username}", HttpMethod.GET), ] expected_edges = [ ( expected_nodes[0], expected_nodes[1], (None, "201"), { "response_id": "201", "chain_id": None, "detail": LinkDetail( link_type=LinkType.LINK, name="userByUsername", description="", parameters={"username": "******"}, requestBody=None, requestBodyParameters={}, ), }, ), ( expected_nodes[1], expected_nodes[2], (None, "200"), { "response_id": "200", "chain_id": None, "detail": LinkDetail( link_type=LinkType.LINK, name="userRepositories", description="Get list of repositories", parameters={"username": "******"}, requestBody=None, requestBodyParameters={}, ), }, ), ] assert [node for node in apigraph.graph.nodes] == expected_nodes assert [ edge for edge in apigraph.graph.edges(data=True, keys=True) ] == expected_edges
def test_security_resolution(): """ OpenAPI allows to specify authentication (`security`) method and credentials at the document level, but with per-Operation overrides. There are four possible cases: 1. operation inherits global security options from OpenAPI element 2. operation overrides with [] to remove all security requirements 3. operation references a scheme from components that's not in global 4. operation overrides with a subset of a global (there'd also be a 5th option combining 3+4, we'll assume supported due to implementation) In Apigraph we want to resolve the overrides and attach the correct security scheme to each node, i.e. for later use in a request-plan. """ doc_uri = fixture_uri("security.yaml") apigraph = APIGraph(doc_uri) assert apigraph.docs.keys() == {doc_uri} doc = apigraph.docs[doc_uri] assert doc.security == [ {"httpBearer": []}, {"OAuth2Password": ["read"]}, ] assert ( doc.paths["/2.0/users/{username}"].get.security is None ) # no override defined on operation # sorted (abitrarily in path order for sake of test) expected_nodes = [ ( NodeKey(doc_uri, "/1.0/users/{username}", HttpMethod.GET), { "detail": OperationDetail( path="/1.0/users/{username}", method=HttpMethod.GET, summary="", description="", parameters={ ParamKey("username", In.PATH): Parameter( **{ "name": "username", "in": In.PATH, "required": True, "schema": {"type": "string"}, } ), }, requestBody=None, # override: only httpBearer accepted security_schemes={ frozenset({doc.components.securitySchemes["httpBearer"]}), }, ) }, ), ( NodeKey(doc_uri, "/2.0/repositories/{username}", HttpMethod.GET), { "detail": OperationDetail( path="/2.0/repositories/{username}", method=HttpMethod.GET, summary="", description="", parameters={ ParamKey("username", In.PATH): Parameter( **{ "name": "username", "in": In.PATH, "required": True, "schema": {"type": "string"}, } ), }, requestBody=None, # override: only apiKey accepted security_schemes={ frozenset({doc.components.securitySchemes["apiKey"]}), }, ) }, ), ( NodeKey(doc_uri, "/2.0/users", HttpMethod.POST), { "detail": OperationDetail( path="/2.0/users", method=HttpMethod.POST, summary="", description="", parameters={}, requestBody=RequestBody( **{ "content": { "application/json": { "schema": { "type": "object", "properties": { "username": {"type": "string"}, "uuid": {"type": "string"}, }, }, }, }, } ), # override: no security required security_schemes=set(), ) }, ), ( NodeKey(doc_uri, "/2.0/users/{username}", HttpMethod.GET), { "detail": OperationDetail( path="/2.0/users/{username}", method=HttpMethod.GET, summary="", description="", parameters={ ParamKey("username", In.PATH): Parameter( **{ "name": "username", "in": In.PATH, "required": True, "schema": {"type": "string"}, } ), }, requestBody=None, # no security override, either httpBearer or OAuth2Password accepted security_schemes={ frozenset({doc.components.securitySchemes["httpBearer"]}), frozenset({doc.components.securitySchemes["OAuth2Password"]}), }, ) }, ), ] assert sorted([node for node in apigraph.graph.nodes(data=True)]) == expected_nodes
def test_invalid(fixture, exception): doc_uri = fixture_uri(fixture) with pytest.raises(exception): APIGraph(doc_uri)