コード例 #1
0
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
コード例 #2
0
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
コード例 #3
0
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
コード例 #4
0
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
コード例 #5
0
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
コード例 #6
0
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
コード例 #7
0
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
コード例 #8
0
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
コード例 #9
0
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
コード例 #10
0
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,
        )
コード例 #11
0
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)
コード例 #12
0
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
コード例 #13
0
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
コード例 #14
0
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