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.
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.
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.