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 noOwnerMixin(see Models - OwnerMixin coverage). pagination=Trueis required on every type decorator.fields="__all__"is the default. Useexclude=[...]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 fieldsand# Related modelscomments. - Cross-type refs use the lazy pattern
Annotated["TypeName", strawberry.lazy("module.path")] | Noneto 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
ACIEndpointGroupFilterandACIUSegEndpointGroupFilter ACIUSegAttributeBaseFilterMixin(filters/tenant/endpoint_groups.py)- shared by
ACIUSegNetworkAttributeFilter ACIEsgSelectorBaseFilterMixin(filters/tenant/endpoint_security_groups.py)- shared by
ACIEsgEndpointGroupSelectorFilterandACIEsgEndpointSelectorFilter
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.
StrFilterLookup compat shim#
The filter mixin imports StrFilterLookup with a fallback:
try:
from strawberry_django import StrFilterLookup
except ImportError:
from strawberry_django import FilterLookup as StrFilterLookup
Remove once NetBox floor is 4.6.0+
StrFilterLookup was introduced in strawberry-django shipped
with NetBox 4.6. While the plugin supports NetBox 4.5.0 through
4.5.10, the fallback to FilterLookup is required. Once the
PluginConfig.min_version is raised to 4.6.0, drop the
try/except and import StrFilterLookup directly.
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 theChoicessuffix on the source class withEnum. - 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.