Skip to content

GraphQL API#

The plugin's GraphQL schema is built on strawberry + strawberry-django, via NetBox's NetBoxObjectType / NetBoxModelFilter base classes.

File layout under netbox_aci_plugin/graphql/:

File Content
types.py One <Model>Type class per model
schema.py The Query type composing all model fields
enums.py ChoiceSet to strawberry.enum re-exports
filter_lookups.py Project-specific lookup overrides
filters/<domain>/<model>.py Per-model Filter dataclasses
filters/mixins.py Shared ACIBaseFilterMixin

Types#

All <Model>Type classes live in a single graphql/types.py. Each type is registered with the model's filter via @strawberry_django.type:

@strawberry_django.type(
    models.ACIBridgeDomain,
    fields="__all__",
    filters=ACIBridgeDomainFilter,
    pagination=True,
)
class ACIBridgeDomainType(OwnerMixin, NetBoxObjectType):
    """GraphQL type definition for the ACIBridgeDomain model."""

    # Model fields
    aci_tenant: Annotated["ACITenantType", strawberry.lazy("...")] | None
    aci_vrf: Annotated["ACIVRFType", strawberry.lazy("...")] | None
    nb_tenant: (
        Annotated[
            "TenantType",
            strawberry.lazy("tenancy.graphql.types"),
        ]
        | None
    )

    # Related models
    aci_bridge_domain_subnets: list[
        Annotated["ACIBridgeDomainSubnetType", strawberry.lazy("...")]
    ]

Rules:

  • Primary models: class <Model>Type(OwnerMixin, NetBoxObjectType).
  • Relation / binding models: class <Model>Type(NetBoxObjectType), with no OwnerMixin (see Models - OwnerMixin coverage).
  • pagination=True is required on every type decorator.
  • fields="__all__" is the default. Use exclude=[...] for both GenericForeignKey component fields (e.g. scope_type, scope_id, aci_object_id, aci_object_type) and denormalized cache fields that should not surface in the API (e.g. _aci_endpoint_group, _ip_address).
  • Sections marked with # Model fields and # Related models comments.
  • Cross-type refs use the lazy pattern Annotated["TypeName", strawberry.lazy("module.path")] | None to avoid circular imports.

Custom @strawberry_django.field resolvers#

For computed fields (e.g. a GFK's scope polymorphic resolver), declare a @strawberry_django.field:

@strawberry_django.field(description="Scope Object")
def scope(
    self,
) -> Annotated[
    Annotated["LocationType", strawberry.lazy("dcim.graphql.types")]
    | Annotated["RegionType", strawberry.lazy("dcim.graphql.types")]
    # ...
]:
    return self.scope

Filters#

Per-domain filters live under graphql/filters/<domain>/<model>.py. Every primary ACI filter subclasses ACIBaseFilterMixin. Exceptions: child/Binding filters for models that have no name, name_alias, or description fields (e.g. ACIBridgeDomainL3OutBindingFilter) use the plain NetBoxModelFilter base instead; and primary models that lack name_alias (currently only ACIFabric) declare their fields manually and extend ScopedFilterMixin, NetBoxModelFilter rather than ACIBaseFilterMixin (graphql/filters/fabric/fabrics.py).

@dataclass
class ACIBaseFilterMixin(NetBoxModelFilter):
    """Base GraphQL filter mixin for ACI models."""

    name: StrFilterLookup[str] | None = strawberry_django.filter_field()
    name_alias: StrFilterLookup[str] | None = strawberry_django.filter_field()
    description: StrFilterLookup[str] | None = strawberry_django.filter_field()

    nb_tenant: (
        Annotated[
            "TenantFilter",
            strawberry.lazy("tenancy.graphql.filters"),
        ]
        | None
    ) = strawberry_django.filter_field()
    nb_tenant_id: ID | None = strawberry_django.filter_field()
    nb_tenant_group: (
        Annotated["TenantGroupFilter", strawberry.lazy("tenancy.graphql.filters")]
        | None
    ) = strawberry_django.filter_field()
    nb_tenant_group_id: (
        Annotated["TreeNodeFilter", strawberry.lazy("netbox.graphql.filter_lookups")]
        | None
    ) = strawberry_django.filter_field()

It provides the universal text fields (name, name_alias, description) and the NetBox-tenant scope pair (nb_tenant / nb_tenant_id / nb_tenant_group / nb_tenant_group_id).

Filter inheritance layering#

Some domains define an intermediate @dataclass mixin that still subclasses ACIBaseFilterMixin, adding domain-specific shared fields before concrete filter classes specialise further:

  • ACIEndpointGroupBaseFilterMixin (filters/tenant/endpoint_groups.py)
  • shared by ACIEndpointGroupFilter and ACIUSegEndpointGroupFilter
  • ACIUSegAttributeBaseFilterMixin (filters/tenant/endpoint_groups.py)
  • shared by ACIUSegNetworkAttributeFilter
  • ACIEsgSelectorBaseFilterMixin (filters/tenant/endpoint_security_groups.py)
  • shared by ACIEsgEndpointGroupSelectorFilter and ACIEsgEndpointSelectorFilter

Scoped models (e.g. ACIPod) also mix in NetBox core's ScopedFilterMixin from dcim.graphql.filter_mixins alongside ACIBaseFilterMixin.

String field coverage#

ALL model string fields must be exposed as GraphQL filter fields with StrFilterLookup[str] lookup type. This includes _policy_name and _route_map_name fields, not just FK and boolean fields. If a model has a CharField or TextField that isn't covered by the base mixin, add it explicitly in the model's filter class.

ArrayField filters via StringArrayLookup#

Model fields backed by ArrayField (e.g. security_domains, dhcp_labels) must be exposed as GraphQL filters using StringArrayLookup from netbox.graphql.filter_lookups:

from typing import TYPE_CHECKING, Annotated

if TYPE_CHECKING:
    from netbox.graphql.filter_lookups import StringArrayLookup


@strawberry_django.filter_type(models.ACIRoutedDomain, lookups=True)
class ACIRoutedDomainFilter(ACIBaseFilterMixin):
    security_domains: (
        Annotated[
            "StringArrayLookup",
            strawberry.lazy("netbox.graphql.filter_lookups"),
        ]
        | None
    ) = strawberry_django.filter_field()

Import StringArrayLookup inside TYPE_CHECKING to satisfy ruff's F821 check. The runtime uses strawberry.lazy(...) for the actual resolution.

Enums#

graphql/enums.py re-exports every ChoiceSet from choices.py as a strawberry enum:

import strawberry

from ..choices import (
    BDMultiDestinationFloodingChoices,
    BDUnknownMulticastChoices,
    BDUnknownUnicastChoices,
    # ...
)

__all__ = (
    "BDMultiDestinationFloodingEnum",
    "BDUnknownMulticastEnum",
    "BDUnknownUnicastEnum",
    # ...
)

# Bridge Domain

BDMultiDestinationFloodingEnum = strawberry.enum(
    BDMultiDestinationFloodingChoices.as_enum()
)
BDUnknownMulticastEnum = strawberry.enum(BDUnknownMulticastChoices.as_enum())
BDUnknownUnicastEnum = strawberry.enum(BDUnknownUnicastChoices.as_enum())


# Contract Filter

ContractFilterARPOpenPeripheralCodesEnum = strawberry.enum(
    ContractFilterARPOpenPeripheralCodesChoices.as_enum()
)
# ...

Rules:

  • One enum per ChoiceSet. Name pattern: replace the Choices suffix on the source class with Enum.
  • Group by ACI sub-domain with # <Domain> section comments (Bridge Domain, Contract Filter, Contract, Contract Relation, ...).
  • Maintain the __all__ tuple in sorted order. New entries get inserted alphabetically.

filter_lookups.py#

Project-specific lookup type overrides. Currently houses one specialization for TCP rule array filtering:

import strawberry
from netbox.graphql.filter_lookups import ArrayLookup
from .enums import ContractFilterTCPRulesEnum


@strawberry.input(
    one_of=True,
    description="Lookup for Array fields. Only one of the lookup fields can be set.",
)
class TCPRulesArrayLookup(ArrayLookup[ContractFilterTCPRulesEnum]):
    """Specialized lookup for TCP rules in an array field."""

    pass

New project-specific lookups go in this file. Don't reach into netbox.graphql.filter_lookups from arbitrary filter modules; funnel them through filter_lookups.py so the surface is greppable.

Schema composition#

graphql/schema.py declares a single Query type. For each <Model>Type, it exposes both singular and list fields via strawberry_django.field():

@strawberry.type(name="Query")
class NetBoxACIQuery:
    """GraphQL query definition for the NetBox ACI Plugin."""

    aci_fabric: ACIFabricType = strawberry_django.field()
    aci_fabric_list: list[ACIFabricType] = strawberry_django.field()

    aci_pod: ACIPodType = strawberry_django.field()
    aci_pod_list: list[ACIPodType] = strawberry_django.field()

    aci_node: ACINodeType = strawberry_django.field()
    aci_node_list: list[ACINodeType] = strawberry_django.field()
    # ...

Naming convention:

  • Singular: aci_<snake_case_model> (e.g. aci_fabric, aci_bridge_domain, aci_contract_relation).
  • List: aci_<snake_case_model>_list.

Both registered via strawberry_django.field(); no extra arguments needed. The type's filters= declaration on @strawberry_django.type wires up the query parameters automatically.

Extending NetBox's own GraphQL types#

The plugin adds fields to core types (an ACI Node Interface on dcim.Interface, the ACI Tenants on tenancy.Tenant) through netbox_aci_plugin/graphql_extensions/.

NetBox discovers that package by name. netbox/plugins/__init__.py maps the graphql_type_extensions resource to the dotted path graphql_extensions.type_extensions, so both the package name and the module-level type_extensions list are load-bearing. Renaming either one silently unregisters every extension rather than raising. A sibling filter_extensions name exists for filters, which the plugin does not use today.

Each extension is a plain @strawberry.type carrying a models list of app_label.model_name targets:

@strawberry.type
class InterfaceTypeExtension:
    """ACI additions to NetBox's Interface type."""

    models = ["dcim.interface"]

Four rules make the difference between this working and failing in ways that are hard to trace.

Keep the package a sibling of graphql/, never a submodule. The graphql/__init__.py assembles the plugin's own types, and it runs before these extensions register. No module under graphql_extensions/ may import a core GraphQL module at import time.

Reference plugin types lazily. Return annotations use Annotated["ACINodeInterfaceType", strawberry.lazy(...)] with the real import parked behind TYPE_CHECKING. A direct import reintroduces the ordering problem the package layout exists to avoid.

Prefetch through RestrictedPrefetch, not prefetch_related. An extension field renders objects the requesting user may not be allowed to see, so the prefetch has to carry the user and the action:

@strawberry_django.field(
    prefetch_related=lambda info: RestrictedPrefetch(
        "aci_node_interface",
        info.context.request.user,
        "view",
        queryset=ACINodeInterface.objects.all(),
    ),
)

Catch ObjectDoesNotExist on a reverse one-to-one. Django raises rather than returning None when the relation is unset, and the restricted prefetch produces the same exception when the related object exists but is filtered out. Both cases must resolve to None:

try:
    return self.aci_node_interface
except ObjectDoesNotExist:
    return None

A reverse many-to-one needs none of this, since .all() on an empty manager is simply an empty list.