From 0c4d0fa2e8094ea4b08fcfc6db1b11c27201097f Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 14 Oct 2025 13:46:30 -0700 Subject: [PATCH 01/10] 19724 pagingate graphql queries --- netbox/circuits/graphql/schema.py | 27 ++++---- netbox/core/graphql/schema.py | 7 +- netbox/dcim/graphql/schema.py | 91 +++++++++++++------------ netbox/extras/graphql/schema.py | 37 +++++----- netbox/ipam/graphql/schema.py | 39 ++++++----- netbox/netbox/tests/test_graphql.py | 20 ++++-- netbox/tenancy/graphql/schema.py | 15 ++-- netbox/users/graphql/schema.py | 7 +- netbox/virtualization/graphql/schema.py | 15 ++-- netbox/vpn/graphql/schema.py | 23 +++---- netbox/wireless/graphql/schema.py | 9 ++- 11 files changed, 145 insertions(+), 145 deletions(-) diff --git a/netbox/circuits/graphql/schema.py b/netbox/circuits/graphql/schema.py index 63bd7bba635..481610fd5ac 100644 --- a/netbox/circuits/graphql/schema.py +++ b/netbox/circuits/graphql/schema.py @@ -1,7 +1,6 @@ -from typing import List - import strawberry import strawberry_django +from strawberry_django.pagination import OffsetPaginated from .types import * @@ -9,34 +8,36 @@ @strawberry.type(name="Query") class CircuitsQuery: circuit: CircuitType = strawberry_django.field() - circuit_list: List[CircuitType] = strawberry_django.field() + circuit_list: OffsetPaginated[CircuitType] = strawberry_django.offset_paginated() circuit_termination: CircuitTerminationType = strawberry_django.field() - circuit_termination_list: List[CircuitTerminationType] = strawberry_django.field() + circuit_termination_list: OffsetPaginated[CircuitTerminationType] = strawberry_django.offset_paginated() circuit_type: CircuitTypeType = strawberry_django.field() - circuit_type_list: List[CircuitTypeType] = strawberry_django.field() + circuit_type_list: OffsetPaginated[CircuitTypeType] = strawberry_django.offset_paginated() circuit_group: CircuitGroupType = strawberry_django.field() - circuit_group_list: List[CircuitGroupType] = strawberry_django.field() + circuit_group_list: OffsetPaginated[CircuitGroupType] = strawberry_django.offset_paginated() circuit_group_assignment: CircuitGroupAssignmentType = strawberry_django.field() - circuit_group_assignment_list: List[CircuitGroupAssignmentType] = strawberry_django.field() + circuit_group_assignment_list: OffsetPaginated[CircuitGroupAssignmentType] = strawberry_django.offset_paginated() provider: ProviderType = strawberry_django.field() - provider_list: List[ProviderType] = strawberry_django.field() + provider_list: OffsetPaginated[ProviderType] = strawberry_django.offset_paginated() provider_account: ProviderAccountType = strawberry_django.field() - provider_account_list: List[ProviderAccountType] = strawberry_django.field() + provider_account_list: OffsetPaginated[ProviderAccountType] = strawberry_django.offset_paginated() provider_network: ProviderNetworkType = strawberry_django.field() - provider_network_list: List[ProviderNetworkType] = strawberry_django.field() + provider_network_list: OffsetPaginated[ProviderNetworkType] = strawberry_django.offset_paginated() virtual_circuit: VirtualCircuitType = strawberry_django.field() - virtual_circuit_list: List[VirtualCircuitType] = strawberry_django.field() + virtual_circuit_list: OffsetPaginated[VirtualCircuitType] = strawberry_django.offset_paginated() virtual_circuit_termination: VirtualCircuitTerminationType = strawberry_django.field() - virtual_circuit_termination_list: List[VirtualCircuitTerminationType] = strawberry_django.field() + virtual_circuit_termination_list: OffsetPaginated[VirtualCircuitTerminationType] = ( + strawberry_django.offset_paginated() + ) virtual_circuit_type: VirtualCircuitTypeType = strawberry_django.field() - virtual_circuit_type_list: List[VirtualCircuitTypeType] = strawberry_django.field() + virtual_circuit_type_list: OffsetPaginated[VirtualCircuitTypeType] = strawberry_django.offset_paginated() diff --git a/netbox/core/graphql/schema.py b/netbox/core/graphql/schema.py index a77c57c86b1..9f615a78b0c 100644 --- a/netbox/core/graphql/schema.py +++ b/netbox/core/graphql/schema.py @@ -1,7 +1,6 @@ -from typing import List - import strawberry import strawberry_django +from strawberry_django.pagination import OffsetPaginated from .types import * @@ -9,7 +8,7 @@ @strawberry.type(name="Query") class CoreQuery: data_file: DataFileType = strawberry_django.field() - data_file_list: List[DataFileType] = strawberry_django.field() + data_file_list: OffsetPaginated[DataFileType] = strawberry_django.offset_paginated() data_source: DataSourceType = strawberry_django.field() - data_source_list: List[DataSourceType] = strawberry_django.field() + data_source_list: OffsetPaginated[DataSourceType] = strawberry_django.offset_paginated() diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 1b0661bc2c6..5156fb1a696 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -1,7 +1,6 @@ -from typing import List - import strawberry import strawberry_django +from strawberry_django.pagination import OffsetPaginated from .types import * @@ -9,130 +8,132 @@ @strawberry.type(name="Query") class DCIMQuery: cable: CableType = strawberry_django.field() - cable_list: List[CableType] = strawberry_django.field() + cable_list: OffsetPaginated[CableType] = strawberry_django.offset_paginated() console_port: ConsolePortType = strawberry_django.field() - console_port_list: List[ConsolePortType] = strawberry_django.field() + console_port_list: OffsetPaginated[ConsolePortType] = strawberry_django.offset_paginated() console_port_template: ConsolePortTemplateType = strawberry_django.field() - console_port_template_list: List[ConsolePortTemplateType] = strawberry_django.field() + console_port_template_list: OffsetPaginated[ConsolePortTemplateType] = strawberry_django.offset_paginated() console_server_port: ConsoleServerPortType = strawberry_django.field() - console_server_port_list: List[ConsoleServerPortType] = strawberry_django.field() + console_server_port_list: OffsetPaginated[ConsoleServerPortType] = strawberry_django.offset_paginated() console_server_port_template: ConsoleServerPortTemplateType = strawberry_django.field() - console_server_port_template_list: List[ConsoleServerPortTemplateType] = strawberry_django.field() + console_server_port_template_list: OffsetPaginated[ConsoleServerPortTemplateType] = ( + strawberry_django.offset_paginated() + ) device: DeviceType = strawberry_django.field() - device_list: List[DeviceType] = strawberry_django.field() + device_list: OffsetPaginated[DeviceType] = strawberry_django.offset_paginated() device_bay: DeviceBayType = strawberry_django.field() - device_bay_list: List[DeviceBayType] = strawberry_django.field() + device_bay_list: OffsetPaginated[DeviceBayType] = strawberry_django.offset_paginated() device_bay_template: DeviceBayTemplateType = strawberry_django.field() - device_bay_template_list: List[DeviceBayTemplateType] = strawberry_django.field() + device_bay_template_list: OffsetPaginated[DeviceBayTemplateType] = strawberry_django.offset_paginated() device_role: DeviceRoleType = strawberry_django.field() - device_role_list: List[DeviceRoleType] = strawberry_django.field() + device_role_list: OffsetPaginated[DeviceRoleType] = strawberry_django.offset_paginated() device_type: DeviceTypeType = strawberry_django.field() - device_type_list: List[DeviceTypeType] = strawberry_django.field() + device_type_list: OffsetPaginated[DeviceTypeType] = strawberry_django.offset_paginated() front_port: FrontPortType = strawberry_django.field() - front_port_list: List[FrontPortType] = strawberry_django.field() + front_port_list: OffsetPaginated[FrontPortType] = strawberry_django.offset_paginated() front_port_template: FrontPortTemplateType = strawberry_django.field() - front_port_template_list: List[FrontPortTemplateType] = strawberry_django.field() + front_port_template_list: OffsetPaginated[FrontPortTemplateType] = strawberry_django.offset_paginated() mac_address: MACAddressType = strawberry_django.field() - mac_address_list: List[MACAddressType] = strawberry_django.field() + mac_address_list: OffsetPaginated[MACAddressType] = strawberry_django.offset_paginated() interface: InterfaceType = strawberry_django.field() - interface_list: List[InterfaceType] = strawberry_django.field() + interface_list: OffsetPaginated[InterfaceType] = strawberry_django.offset_paginated() interface_template: InterfaceTemplateType = strawberry_django.field() - interface_template_list: List[InterfaceTemplateType] = strawberry_django.field() + interface_template_list: OffsetPaginated[InterfaceTemplateType] = strawberry_django.offset_paginated() inventory_item: InventoryItemType = strawberry_django.field() - inventory_item_list: List[InventoryItemType] = strawberry_django.field() + inventory_item_list: OffsetPaginated[InventoryItemType] = strawberry_django.offset_paginated() inventory_item_role: InventoryItemRoleType = strawberry_django.field() - inventory_item_role_list: List[InventoryItemRoleType] = strawberry_django.field() + inventory_item_role_list: OffsetPaginated[InventoryItemRoleType] = strawberry_django.offset_paginated() inventory_item_template: InventoryItemTemplateType = strawberry_django.field() - inventory_item_template_list: List[InventoryItemTemplateType] = strawberry_django.field() + inventory_item_template_list: OffsetPaginated[InventoryItemTemplateType] = strawberry_django.offset_paginated() location: LocationType = strawberry_django.field() - location_list: List[LocationType] = strawberry_django.field() + location_list: OffsetPaginated[LocationType] = strawberry_django.offset_paginated() manufacturer: ManufacturerType = strawberry_django.field() - manufacturer_list: List[ManufacturerType] = strawberry_django.field() + manufacturer_list: OffsetPaginated[ManufacturerType] = strawberry_django.offset_paginated() module: ModuleType = strawberry_django.field() - module_list: List[ModuleType] = strawberry_django.field() + module_list: OffsetPaginated[ModuleType] = strawberry_django.offset_paginated() module_bay: ModuleBayType = strawberry_django.field() - module_bay_list: List[ModuleBayType] = strawberry_django.field() + module_bay_list: OffsetPaginated[ModuleBayType] = strawberry_django.offset_paginated() module_bay_template: ModuleBayTemplateType = strawberry_django.field() - module_bay_template_list: List[ModuleBayTemplateType] = strawberry_django.field() + module_bay_template_list: OffsetPaginated[ModuleBayTemplateType] = strawberry_django.offset_paginated() module_type_profile: ModuleTypeProfileType = strawberry_django.field() - module_type_profile_list: List[ModuleTypeProfileType] = strawberry_django.field() + module_type_profile_list: OffsetPaginated[ModuleTypeProfileType] = strawberry_django.offset_paginated() module_type: ModuleTypeType = strawberry_django.field() - module_type_list: List[ModuleTypeType] = strawberry_django.field() + module_type_list: OffsetPaginated[ModuleTypeType] = strawberry_django.offset_paginated() platform: PlatformType = strawberry_django.field() - platform_list: List[PlatformType] = strawberry_django.field() + platform_list: OffsetPaginated[PlatformType] = strawberry_django.offset_paginated() power_feed: PowerFeedType = strawberry_django.field() - power_feed_list: List[PowerFeedType] = strawberry_django.field() + power_feed_list: OffsetPaginated[PowerFeedType] = strawberry_django.offset_paginated() power_outlet: PowerOutletType = strawberry_django.field() - power_outlet_list: List[PowerOutletType] = strawberry_django.field() + power_outlet_list: OffsetPaginated[PowerOutletType] = strawberry_django.offset_paginated() power_outlet_template: PowerOutletTemplateType = strawberry_django.field() - power_outlet_template_list: List[PowerOutletTemplateType] = strawberry_django.field() + power_outlet_template_list: OffsetPaginated[PowerOutletTemplateType] = strawberry_django.offset_paginated() power_panel: PowerPanelType = strawberry_django.field() - power_panel_list: List[PowerPanelType] = strawberry_django.field() + power_panel_list: OffsetPaginated[PowerPanelType] = strawberry_django.offset_paginated() power_port: PowerPortType = strawberry_django.field() - power_port_list: List[PowerPortType] = strawberry_django.field() + power_port_list: OffsetPaginated[PowerPortType] = strawberry_django.offset_paginated() power_port_template: PowerPortTemplateType = strawberry_django.field() - power_port_template_list: List[PowerPortTemplateType] = strawberry_django.field() + power_port_template_list: OffsetPaginated[PowerPortTemplateType] = strawberry_django.offset_paginated() rack_type: RackTypeType = strawberry_django.field() - rack_type_list: List[RackTypeType] = strawberry_django.field() + rack_type_list: OffsetPaginated[RackTypeType] = strawberry_django.offset_paginated() rack: RackType = strawberry_django.field() - rack_list: List[RackType] = strawberry_django.field() + rack_list: OffsetPaginated[RackType] = strawberry_django.offset_paginated() rack_reservation: RackReservationType = strawberry_django.field() - rack_reservation_list: List[RackReservationType] = strawberry_django.field() + rack_reservation_list: OffsetPaginated[RackReservationType] = strawberry_django.offset_paginated() rack_role: RackRoleType = strawberry_django.field() - rack_role_list: List[RackRoleType] = strawberry_django.field() + rack_role_list: OffsetPaginated[RackRoleType] = strawberry_django.offset_paginated() rear_port: RearPortType = strawberry_django.field() - rear_port_list: List[RearPortType] = strawberry_django.field() + rear_port_list: OffsetPaginated[RearPortType] = strawberry_django.offset_paginated() rear_port_template: RearPortTemplateType = strawberry_django.field() - rear_port_template_list: List[RearPortTemplateType] = strawberry_django.field() + rear_port_template_list: OffsetPaginated[RearPortTemplateType] = strawberry_django.offset_paginated() region: RegionType = strawberry_django.field() - region_list: List[RegionType] = strawberry_django.field() + region_list: OffsetPaginated[RegionType] = strawberry_django.offset_paginated() site: SiteType = strawberry_django.field() - site_list: List[SiteType] = strawberry_django.field() + site_list: OffsetPaginated[SiteType] = strawberry_django.offset_paginated() site_group: SiteGroupType = strawberry_django.field() - site_group_list: List[SiteGroupType] = strawberry_django.field() + site_group_list: OffsetPaginated[SiteGroupType] = strawberry_django.offset_paginated() virtual_chassis: VirtualChassisType = strawberry_django.field() - virtual_chassis_list: List[VirtualChassisType] = strawberry_django.field() + virtual_chassis_list: OffsetPaginated[VirtualChassisType] = strawberry_django.offset_paginated() virtual_device_context: VirtualDeviceContextType = strawberry_django.field() - virtual_device_context_list: List[VirtualDeviceContextType] = strawberry_django.field() + virtual_device_context_list: OffsetPaginated[VirtualDeviceContextType] = strawberry_django.offset_paginated() diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index 60d596f01ac..9f7b29971e6 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -1,7 +1,6 @@ -from typing import List - import strawberry import strawberry_django +from strawberry_django.pagination import OffsetPaginated from .types import * @@ -9,52 +8,52 @@ @strawberry.type(name="Query") class ExtrasQuery: config_context: ConfigContextType = strawberry_django.field() - config_context_list: List[ConfigContextType] = strawberry_django.field() + config_context_list: OffsetPaginated[ConfigContextType] = strawberry_django.offset_paginated() config_context_profile: ConfigContextProfileType = strawberry_django.field() - config_context_profile_list: List[ConfigContextProfileType] = strawberry_django.field() + config_context_profile_list: OffsetPaginated[ConfigContextProfileType] = strawberry_django.offset_paginated() config_template: ConfigTemplateType = strawberry_django.field() - config_template_list: List[ConfigTemplateType] = strawberry_django.field() + config_template_list: OffsetPaginated[ConfigTemplateType] = strawberry_django.offset_paginated() custom_field: CustomFieldType = strawberry_django.field() - custom_field_list: List[CustomFieldType] = strawberry_django.field() + custom_field_list: OffsetPaginated[CustomFieldType] = strawberry_django.offset_paginated() custom_field_choice_set: CustomFieldChoiceSetType = strawberry_django.field() - custom_field_choice_set_list: List[CustomFieldChoiceSetType] = strawberry_django.field() + custom_field_choice_set_list: OffsetPaginated[CustomFieldChoiceSetType] = strawberry_django.offset_paginated() custom_link: CustomLinkType = strawberry_django.field() - custom_link_list: List[CustomLinkType] = strawberry_django.field() + custom_link_list: OffsetPaginated[CustomLinkType] = strawberry_django.offset_paginated() export_template: ExportTemplateType = strawberry_django.field() - export_template_list: List[ExportTemplateType] = strawberry_django.field() + export_template_list: OffsetPaginated[ExportTemplateType] = strawberry_django.offset_paginated() image_attachment: ImageAttachmentType = strawberry_django.field() - image_attachment_list: List[ImageAttachmentType] = strawberry_django.field() + image_attachment_list: OffsetPaginated[ImageAttachmentType] = strawberry_django.offset_paginated() saved_filter: SavedFilterType = strawberry_django.field() - saved_filter_list: List[SavedFilterType] = strawberry_django.field() + saved_filter_list: OffsetPaginated[SavedFilterType] = strawberry_django.offset_paginated() table_config: TableConfigType = strawberry_django.field() - table_config_list: List[TableConfigType] = strawberry_django.field() + table_config_list: OffsetPaginated[TableConfigType] = strawberry_django.offset_paginated() journal_entry: JournalEntryType = strawberry_django.field() - journal_entry_list: List[JournalEntryType] = strawberry_django.field() + journal_entry_list: OffsetPaginated[JournalEntryType] = strawberry_django.offset_paginated() notification: NotificationType = strawberry_django.field() - notification_list: List[NotificationType] = strawberry_django.field() + notification_list: OffsetPaginated[NotificationType] = strawberry_django.offset_paginated() notification_group: NotificationGroupType = strawberry_django.field() - notification_group_list: List[NotificationGroupType] = strawberry_django.field() + notification_group_list: OffsetPaginated[NotificationGroupType] = strawberry_django.offset_paginated() subscription: SubscriptionType = strawberry_django.field() - subscription_list: List[SubscriptionType] = strawberry_django.field() + subscription_list: OffsetPaginated[SubscriptionType] = strawberry_django.offset_paginated() tag: TagType = strawberry_django.field() - tag_list: List[TagType] = strawberry_django.field() + tag_list: OffsetPaginated[TagType] = strawberry_django.offset_paginated() webhook: WebhookType = strawberry_django.field() - webhook_list: List[WebhookType] = strawberry_django.field() + webhook_list: OffsetPaginated[WebhookType] = strawberry_django.offset_paginated() event_rule: EventRuleType = strawberry_django.field() - event_rule_list: List[EventRuleType] = strawberry_django.field() + event_rule_list: OffsetPaginated[EventRuleType] = strawberry_django.offset_paginated() diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 5fcf78ea9bc..82f2fe41e34 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -1,7 +1,6 @@ -from typing import List - import strawberry import strawberry_django +from strawberry_django.pagination import OffsetPaginated from .types import * @@ -9,55 +8,55 @@ @strawberry.type(name="Query") class IPAMQuery: asn: ASNType = strawberry_django.field() - asn_list: List[ASNType] = strawberry_django.field() + asn_list: OffsetPaginated[ASNType] = strawberry_django.offset_paginated() asn_range: ASNRangeType = strawberry_django.field() - asn_range_list: List[ASNRangeType] = strawberry_django.field() + asn_range_list: OffsetPaginated[ASNRangeType] = strawberry_django.offset_paginated() aggregate: AggregateType = strawberry_django.field() - aggregate_list: List[AggregateType] = strawberry_django.field() + aggregate_list: OffsetPaginated[AggregateType] = strawberry_django.offset_paginated() ip_address: IPAddressType = strawberry_django.field() - ip_address_list: List[IPAddressType] = strawberry_django.field() + ip_address_list: OffsetPaginated[IPAddressType] = strawberry_django.offset_paginated() ip_range: IPRangeType = strawberry_django.field() - ip_range_list: List[IPRangeType] = strawberry_django.field() + ip_range_list: OffsetPaginated[IPRangeType] = strawberry_django.offset_paginated() prefix: PrefixType = strawberry_django.field() - prefix_list: List[PrefixType] = strawberry_django.field() + prefix_list: OffsetPaginated[PrefixType] = strawberry_django.offset_paginated() rir: RIRType = strawberry_django.field() - rir_list: List[RIRType] = strawberry_django.field() + rir_list: OffsetPaginated[RIRType] = strawberry_django.offset_paginated() role: RoleType = strawberry_django.field() - role_list: List[RoleType] = strawberry_django.field() + role_list: OffsetPaginated[RoleType] = strawberry_django.offset_paginated() route_target: RouteTargetType = strawberry_django.field() - route_target_list: List[RouteTargetType] = strawberry_django.field() + route_target_list: OffsetPaginated[RouteTargetType] = strawberry_django.offset_paginated() service: ServiceType = strawberry_django.field() - service_list: List[ServiceType] = strawberry_django.field() + service_list: OffsetPaginated[ServiceType] = strawberry_django.offset_paginated() service_template: ServiceTemplateType = strawberry_django.field() - service_template_list: List[ServiceTemplateType] = strawberry_django.field() + service_template_list: OffsetPaginated[ServiceTemplateType] = strawberry_django.offset_paginated() fhrp_group: FHRPGroupType = strawberry_django.field() - fhrp_group_list: List[FHRPGroupType] = strawberry_django.field() + fhrp_group_list: OffsetPaginated[FHRPGroupType] = strawberry_django.offset_paginated() fhrp_group_assignment: FHRPGroupAssignmentType = strawberry_django.field() - fhrp_group_assignment_list: List[FHRPGroupAssignmentType] = strawberry_django.field() + fhrp_group_assignment_list: OffsetPaginated[FHRPGroupAssignmentType] = strawberry_django.offset_paginated() vlan: VLANType = strawberry_django.field() - vlan_list: List[VLANType] = strawberry_django.field() + vlan_list: OffsetPaginated[VLANType] = strawberry_django.offset_paginated() vlan_group: VLANGroupType = strawberry_django.field() - vlan_group_list: List[VLANGroupType] = strawberry_django.field() + vlan_group_list: OffsetPaginated[VLANGroupType] = strawberry_django.offset_paginated() vlan_translation_policy: VLANTranslationPolicyType = strawberry_django.field() - vlan_translation_policy_list: List[VLANTranslationPolicyType] = strawberry_django.field() + vlan_translation_policy_list: OffsetPaginated[VLANTranslationPolicyType] = strawberry_django.offset_paginated() vlan_translation_rule: VLANTranslationRuleType = strawberry_django.field() - vlan_translation_rule_list: List[VLANTranslationRuleType] = strawberry_django.field() + vlan_translation_rule_list: OffsetPaginated[VLANTranslationRuleType] = strawberry_django.offset_paginated() vrf: VRFType = strawberry_django.field() - vrf_list: List[VRFType] = strawberry_django.field() + vrf_list: OffsetPaginated[VRFType] = strawberry_django.offset_paginated() diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index ca231526fa7..9fc7e7e9610 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/netbox/netbox/tests/test_graphql.py @@ -88,13 +88,14 @@ def test_graphql_filter_objects(self): url = reverse('graphql') # A valid request should return the filtered list - query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}' + query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {results {id site {id}} total_count}}' response = self.client.post(url, data={'query': query}, format="json", **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) data = json.loads(response.content) self.assertNotIn('errors', data) - self.assertEqual(len(data['data']['location_list']), 1) - self.assertIsNotNone(data['data']['location_list'][0]['site']) + self.assertEqual(len(data['data']['location_list']['results']), 1) + self.assertEqual(data['data']['location_list']['total_count'], 1) + self.assertIsNotNone(data['data']['location_list']['results'][0]['site']) # Test OR logic query = """{ @@ -102,21 +103,26 @@ def test_graphql_filter_objects(self): status: STATUS_PLANNED, OR: {status: STATUS_STAGING} }) { - id site {id} + results { + id site {id} + } + total_count } }""" response = self.client.post(url, data={'query': query}, format="json", **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) data = json.loads(response.content) self.assertNotIn('errors', data) - self.assertEqual(len(data['data']['location_list']), 2) + self.assertEqual(len(data['data']['location_list']['results']), 2) + self.assertEqual(data['data']['location_list']['total_count'], 2) # An invalid request should return an empty list - query = '{location_list(filters: {site_id: "99999"}) {id site {id}}}' # Invalid site ID + query = '{location_list(filters: {site_id: "99999"}) {results {id site {id}} total_count}}' # Invalid site ID response = self.client.post(url, data={'query': query}, format="json", **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) data = json.loads(response.content) - self.assertEqual(len(data['data']['location_list']), 0) + self.assertEqual(len(data['data']['location_list']['results']), 0) + self.assertEqual(data['data']['location_list']['total_count'], 0) # Removing the permissions from location should result in an empty locations list obj_perm.object_types.remove(ObjectType.objects.get_for_model(Location)) diff --git a/netbox/tenancy/graphql/schema.py b/netbox/tenancy/graphql/schema.py index 857d8ddeb44..8138748733c 100644 --- a/netbox/tenancy/graphql/schema.py +++ b/netbox/tenancy/graphql/schema.py @@ -1,7 +1,6 @@ -from typing import List - import strawberry import strawberry_django +from strawberry_django.pagination import OffsetPaginated from .types import * @@ -9,19 +8,19 @@ @strawberry.type(name="Query") class TenancyQuery: tenant: TenantType = strawberry_django.field() - tenant_list: List[TenantType] = strawberry_django.field() + tenant_list: OffsetPaginated[TenantType] = strawberry_django.offset_paginated() tenant_group: TenantGroupType = strawberry_django.field() - tenant_group_list: List[TenantGroupType] = strawberry_django.field() + tenant_group_list: OffsetPaginated[TenantGroupType] = strawberry_django.offset_paginated() contact: ContactType = strawberry_django.field() - contact_list: List[ContactType] = strawberry_django.field() + contact_list: OffsetPaginated[ContactType] = strawberry_django.offset_paginated() contact_role: ContactRoleType = strawberry_django.field() - contact_role_list: List[ContactRoleType] = strawberry_django.field() + contact_role_list: OffsetPaginated[ContactRoleType] = strawberry_django.offset_paginated() contact_group: ContactGroupType = strawberry_django.field() - contact_group_list: List[ContactGroupType] = strawberry_django.field() + contact_group_list: OffsetPaginated[ContactGroupType] = strawberry_django.offset_paginated() contact_assignment: ContactAssignmentType = strawberry_django.field() - contact_assignment_list: List[ContactAssignmentType] = strawberry_django.field() + contact_assignment_list: OffsetPaginated[ContactAssignmentType] = strawberry_django.offset_paginated() diff --git a/netbox/users/graphql/schema.py b/netbox/users/graphql/schema.py index b59266c57eb..a2b2b91eb7e 100644 --- a/netbox/users/graphql/schema.py +++ b/netbox/users/graphql/schema.py @@ -1,7 +1,6 @@ -from typing import List - import strawberry import strawberry_django +from strawberry_django.pagination import OffsetPaginated from .types import * @@ -9,7 +8,7 @@ @strawberry.type(name="Query") class UsersQuery: group: GroupType = strawberry_django.field() - group_list: List[GroupType] = strawberry_django.field() + group_list: OffsetPaginated[GroupType] = strawberry_django.offset_paginated() user: UserType = strawberry_django.field() - user_list: List[UserType] = strawberry_django.field() + user_list: OffsetPaginated[UserType] = strawberry_django.offset_paginated() diff --git a/netbox/virtualization/graphql/schema.py b/netbox/virtualization/graphql/schema.py index 212425814a4..c052d61ee0c 100644 --- a/netbox/virtualization/graphql/schema.py +++ b/netbox/virtualization/graphql/schema.py @@ -1,7 +1,6 @@ -from typing import List - import strawberry import strawberry_django +from strawberry_django.pagination import OffsetPaginated from .types import * @@ -9,19 +8,19 @@ @strawberry.type(name="Query") class VirtualizationQuery: cluster: ClusterType = strawberry_django.field() - cluster_list: List[ClusterType] = strawberry_django.field() + cluster_list: OffsetPaginated[ClusterType] = strawberry_django.offset_paginated() cluster_group: ClusterGroupType = strawberry_django.field() - cluster_group_list: List[ClusterGroupType] = strawberry_django.field() + cluster_group_list: OffsetPaginated[ClusterGroupType] = strawberry_django.offset_paginated() cluster_type: ClusterTypeType = strawberry_django.field() - cluster_type_list: List[ClusterTypeType] = strawberry_django.field() + cluster_type_list: OffsetPaginated[ClusterTypeType] = strawberry_django.offset_paginated() virtual_machine: VirtualMachineType = strawberry_django.field() - virtual_machine_list: List[VirtualMachineType] = strawberry_django.field() + virtual_machine_list: OffsetPaginated[VirtualMachineType] = strawberry_django.offset_paginated() vm_interface: VMInterfaceType = strawberry_django.field() - vm_interface_list: List[VMInterfaceType] = strawberry_django.field() + vm_interface_list: OffsetPaginated[VMInterfaceType] = strawberry_django.offset_paginated() virtual_disk: VirtualDiskType = strawberry_django.field() - virtual_disk_list: List[VirtualDiskType] = strawberry_django.field() + virtual_disk_list: OffsetPaginated[VirtualDiskType] = strawberry_django.offset_paginated() diff --git a/netbox/vpn/graphql/schema.py b/netbox/vpn/graphql/schema.py index 06ccc577d08..82159df0b3f 100644 --- a/netbox/vpn/graphql/schema.py +++ b/netbox/vpn/graphql/schema.py @@ -1,7 +1,6 @@ -from typing import List - import strawberry import strawberry_django +from strawberry_django.pagination import OffsetPaginated from .types import * @@ -9,31 +8,31 @@ @strawberry.type(name="Query") class VPNQuery: ike_policy: IKEPolicyType = strawberry_django.field() - ike_policy_list: List[IKEPolicyType] = strawberry_django.field() + ike_policy_list: OffsetPaginated[IKEPolicyType] = strawberry_django.offset_paginated() ike_proposal: IKEProposalType = strawberry_django.field() - ike_proposal_list: List[IKEProposalType] = strawberry_django.field() + ike_proposal_list: OffsetPaginated[IKEProposalType] = strawberry_django.offset_paginated() ipsec_policy: IPSecPolicyType = strawberry_django.field() - ipsec_policy_list: List[IPSecPolicyType] = strawberry_django.field() + ipsec_policy_list: OffsetPaginated[IPSecPolicyType] = strawberry_django.offset_paginated() ipsec_profile: IPSecProfileType = strawberry_django.field() - ipsec_profile_list: List[IPSecProfileType] = strawberry_django.field() + ipsec_profile_list: OffsetPaginated[IPSecProfileType] = strawberry_django.offset_paginated() ipsec_proposal: IPSecProposalType = strawberry_django.field() - ipsec_proposal_list: List[IPSecProposalType] = strawberry_django.field() + ipsec_proposal_list: OffsetPaginated[IPSecProposalType] = strawberry_django.offset_paginated() l2vpn: L2VPNType = strawberry_django.field() - l2vpn_list: List[L2VPNType] = strawberry_django.field() + l2vpn_list: OffsetPaginated[L2VPNType] = strawberry_django.offset_paginated() l2vpn_termination: L2VPNTerminationType = strawberry_django.field() - l2vpn_termination_list: List[L2VPNTerminationType] = strawberry_django.field() + l2vpn_termination_list: OffsetPaginated[L2VPNTerminationType] = strawberry_django.offset_paginated() tunnel: TunnelType = strawberry_django.field() - tunnel_list: List[TunnelType] = strawberry_django.field() + tunnel_list: OffsetPaginated[TunnelType] = strawberry_django.offset_paginated() tunnel_group: TunnelGroupType = strawberry_django.field() - tunnel_group_list: List[TunnelGroupType] = strawberry_django.field() + tunnel_group_list: OffsetPaginated[TunnelGroupType] = strawberry_django.offset_paginated() tunnel_termination: TunnelTerminationType = strawberry_django.field() - tunnel_termination_list: List[TunnelTerminationType] = strawberry_django.field() + tunnel_termination_list: OffsetPaginated[TunnelTerminationType] = strawberry_django.offset_paginated() diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py index 4f176031f3e..64b293a1313 100644 --- a/netbox/wireless/graphql/schema.py +++ b/netbox/wireless/graphql/schema.py @@ -1,7 +1,6 @@ -from typing import List - import strawberry import strawberry_django +from strawberry_django.pagination import OffsetPaginated from .types import * @@ -9,10 +8,10 @@ @strawberry.type(name="Query") class WirelessQuery: wireless_lan: WirelessLANType = strawberry_django.field() - wireless_lan_list: List[WirelessLANType] = strawberry_django.field() + wireless_lan_list: OffsetPaginated[WirelessLANType] = strawberry_django.offset_paginated() wireless_lan_group: WirelessLANGroupType = strawberry_django.field() - wireless_lan_group_list: List[WirelessLANGroupType] = strawberry_django.field() + wireless_lan_group_list: OffsetPaginated[WirelessLANGroupType] = strawberry_django.offset_paginated() wireless_link: WirelessLinkType = strawberry_django.field() - wireless_link_list: List[WirelessLinkType] = strawberry_django.field() + wireless_link_list: OffsetPaginated[WirelessLinkType] = strawberry_django.offset_paginated() From c2d19119cb8c8b4d07f9859d465771b1eba4fcea Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 14 Oct 2025 13:54:58 -0700 Subject: [PATCH 02/10] 19724 update documentation --- docs/integrations/graphql-api.md | 84 +++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 22 deletions(-) diff --git a/docs/integrations/graphql-api.md b/docs/integrations/graphql-api.md index 39309671cca..7a093e3c59f 100644 --- a/docs/integrations/graphql-api.md +++ b/docs/integrations/graphql-api.md @@ -11,7 +11,7 @@ curl -H "Authorization: Token $TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ http://netbox/graphql/ \ ---data '{"query": "query {circuit_list(filters:{status: STATUS_ACTIVE}) {cid provider {name}}}"}' +--data '{"query": "query {circuit_list(filters:{status: STATUS_ACTIVE}) {results {cid provider {name}}}}"}' ``` The response will include the requested data formatted as JSON: @@ -19,20 +19,22 @@ The response will include the requested data formatted as JSON: ```json { "data": { - "circuits": [ - { - "cid": "1002840283", - "provider": { - "name": "CenturyLink" - } - }, - { - "cid": "1002840457", - "provider": { - "name": "CenturyLink" + "circuit_list": { + "results": [ + { + "cid": "1002840283", + "provider": { + "name": "CenturyLink" + } + }, + { + "cid": "1002840457", + "provider": { + "name": "CenturyLink" + } } - } - ] + ] + } } } ``` @@ -63,7 +65,9 @@ query { status: STATUS_ACTIVE } ) { - name + results { + name + } } } ``` @@ -84,7 +88,9 @@ query { } } ) { - name + results { + name + } } } ``` @@ -94,10 +100,12 @@ Filtering can also be applied to related objects. For example, the following que ``` query { device_list { - id - name - interfaces(filters: {enabled: true}) { + results { + id name + interfaces(filters: {enabled: true}) { + name + } } } } @@ -109,7 +117,8 @@ Certain queries can return multiple types of objects, for example cable terminat ``` { - cable_list { + cable_list { + results { id a_terminations { ... on CircuitTerminationType { @@ -126,6 +135,7 @@ Certain queries can return multiple types of objects, for example cable terminat } } } + } } ``` @@ -133,16 +143,46 @@ The field "class_type" is an easy way to distinguish what type of object it is w ## Pagination -Queries can be paginated by specifying pagination in the query and supplying an offset and optionaly a limit in the query. If no limit is given, a default of 100 is used. Queries are not paginated unless requested in the query. An example paginated query is shown below: +All list queries return paginated results using the `OffsetPaginated` type, which includes: + +- `results`: The list of objects matching the query +- `total_count`: The total number of objects matching the filters (without pagination) +- `page_info`: Pagination metadata including `offset` and `limit` + +By default, queries return up to 100 results. You can control pagination by specifying the `pagination` parameter with `offset` and `limit` values: ``` query { device_list(pagination: { offset: 0, limit: 20 }) { - id + total_count + page_info { + offset + limit + } + results { + id + name + } } } ``` +If you don't need pagination metadata, you can simply query the `results`: + +``` +query { + device_list { + results { + id + name + } + } +} +``` + +!!! note + When not specifying the `pagination` parameter, avoid querying `page_info.limit` as it may return an undefined value. Either provide explicit pagination parameters or only query the `results` and `total_count` fields. + ## Authentication NetBox's GraphQL API uses the same API authentication tokens as its REST API. Authentication tokens are included with requests by attaching an `Authorization` HTTP header in the following form: From 8aa1e2802b190d40687438bbcea47003c4bf51d4 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 14 Oct 2025 14:06:15 -0700 Subject: [PATCH 03/10] 19724 fix tests --- netbox/utilities/testing/api.py | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 56cabef5d8f..29dcf8385e2 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -562,13 +562,27 @@ def _build_query_with_filter(self, name, filter_string): else: fields_string += f'{field.name}\n' - query = f""" - {{ - {name}{filter_string} {{ - {fields_string} + # Check if this is a list query (ends with '_list') + if name.endswith('_list'): + # Wrap fields in 'results' for paginated queries + query = f""" + {{ + {name}{filter_string} {{ + results {{ + {fields_string} + }} + }} }} - }} - """ + """ + else: + # Single object query (no pagination) + query = f""" + {{ + {name}{filter_string} {{ + {fields_string} + }} + }} + """ return query @@ -677,7 +691,7 @@ def test_graphql_list_objects(self): self.assertHttpStatus(response, status.HTTP_200_OK) data = json.loads(response.content) self.assertNotIn('errors', data) - self.assertEqual(len(data['data'][field_name]), 0) + self.assertEqual(len(data['data'][field_name]['results']), 0) # Remove permission constraint obj_perm.constraints = None @@ -688,7 +702,7 @@ def test_graphql_list_objects(self): self.assertHttpStatus(response, status.HTTP_200_OK) data = json.loads(response.content) self.assertNotIn('errors', data) - self.assertEqual(len(data['data'][field_name]), self.model.objects.count()) + self.assertEqual(len(data['data'][field_name]['results']), self.model.objects.count()) @override_settings(LOGIN_REQUIRED=True) def test_graphql_filter_objects(self): @@ -712,7 +726,7 @@ def test_graphql_filter_objects(self): self.assertHttpStatus(response, status.HTTP_200_OK) data = json.loads(response.content) self.assertNotIn('errors', data) - self.assertGreater(len(data['data'][field_name]), 0) + self.assertGreater(len(data['data'][field_name]['results']), 0) class APIViewTestCase( GetObjectViewTestCase, From 730aee9b265ca4b64b7a976446d0410e988ddbb1 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 14 Oct 2025 14:15:16 -0700 Subject: [PATCH 04/10] 19724 fix doc query --- docs/integrations/graphql-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/integrations/graphql-api.md b/docs/integrations/graphql-api.md index 7a093e3c59f..0cb639c3fe1 100644 --- a/docs/integrations/graphql-api.md +++ b/docs/integrations/graphql-api.md @@ -103,7 +103,7 @@ query { results { id name - interfaces(filters: {enabled: true}) { + interfaces(filters: {enabled: {exact: true}}) { name } } From 595b343cd0c3ab2bcf9f87e9cdfe0643ce3a50d9 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 14 Oct 2025 14:38:16 -0700 Subject: [PATCH 05/10] 19724 add doc note --- docs/integrations/graphql-api.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/integrations/graphql-api.md b/docs/integrations/graphql-api.md index 0cb639c3fe1..78d86f11c18 100644 --- a/docs/integrations/graphql-api.md +++ b/docs/integrations/graphql-api.md @@ -49,6 +49,9 @@ NetBox provides both a singular and plural query field for each object type: For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices. +!!! note "Changed in NetBox v4.5" + List queries now return paginated results. The actual objects are contained within the `results` field of the response, along with `total_count` and `page_info` fields for pagination metadata. Prior to v4.5, list queries returned objects directly as an array. + For more detail on constructing GraphQL queries, see the [GraphQL queries documentation](https://graphql.org/learn/queries/). For filtering and lookup syntax, please refer to the [Strawberry Django documentation](https://strawberry.rocks/docs/django/guide/filters). ## Filtering From 91b2d61ea41f7ab4108a7648fabeea956a78d305 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 20 Oct 2025 16:52:46 -0700 Subject: [PATCH 06/10] 19724 Use v2 API for new pagination queries --- netbox/circuits/graphql/schema.py | 38 +++++++ netbox/core/graphql/schema.py | 11 ++ netbox/dcim/graphql/schema.py | 134 ++++++++++++++++++++++++ netbox/extras/graphql/schema.py | 56 ++++++++++ netbox/ipam/graphql/schema.py | 59 +++++++++++ netbox/netbox/graphql/schema.py | 40 +++---- netbox/tenancy/graphql/schema.py | 23 ++++ netbox/users/graphql/schema.py | 11 ++ netbox/virtualization/graphql/schema.py | 23 ++++ netbox/vpn/graphql/schema.py | 35 +++++++ netbox/wireless/graphql/schema.py | 14 +++ 11 files changed, 424 insertions(+), 20 deletions(-) diff --git a/netbox/circuits/graphql/schema.py b/netbox/circuits/graphql/schema.py index 481610fd5ac..3cf2cd52e9b 100644 --- a/netbox/circuits/graphql/schema.py +++ b/netbox/circuits/graphql/schema.py @@ -1,3 +1,5 @@ +from typing import List + import strawberry import strawberry_django from strawberry_django.pagination import OffsetPaginated @@ -5,6 +7,42 @@ from .types import * +@strawberry.type(name="Query") +class CircuitsQueryOld: + circuit: CircuitType = strawberry_django.field() + circuit_list: List[CircuitType] = strawberry_django.field() + + circuit_termination: CircuitTerminationType = strawberry_django.field() + circuit_termination_list: List[CircuitTerminationType] = strawberry_django.field() + + circuit_type: CircuitTypeType = strawberry_django.field() + circuit_type_list: List[CircuitTypeType] = strawberry_django.field() + + circuit_group: CircuitGroupType = strawberry_django.field() + circuit_group_list: List[CircuitGroupType] = strawberry_django.field() + + circuit_group_assignment: CircuitGroupAssignmentType = strawberry_django.field() + circuit_group_assignment_list: List[CircuitGroupAssignmentType] = strawberry_django.field() + + provider: ProviderType = strawberry_django.field() + provider_list: List[ProviderType] = strawberry_django.field() + + provider_account: ProviderAccountType = strawberry_django.field() + provider_account_list: List[ProviderAccountType] = strawberry_django.field() + + provider_network: ProviderNetworkType = strawberry_django.field() + provider_network_list: List[ProviderNetworkType] = strawberry_django.field() + + virtual_circuit: VirtualCircuitType = strawberry_django.field() + virtual_circuit_list: List[VirtualCircuitType] = strawberry_django.field() + + virtual_circuit_termination: VirtualCircuitTerminationType = strawberry_django.field() + virtual_circuit_termination_list: List[VirtualCircuitTerminationType] = strawberry_django.field() + + virtual_circuit_type: VirtualCircuitTypeType = strawberry_django.field() + virtual_circuit_type_list: List[VirtualCircuitTypeType] = strawberry_django.field() + + @strawberry.type(name="Query") class CircuitsQuery: circuit: CircuitType = strawberry_django.field() diff --git a/netbox/core/graphql/schema.py b/netbox/core/graphql/schema.py index 9f615a78b0c..d82944e2978 100644 --- a/netbox/core/graphql/schema.py +++ b/netbox/core/graphql/schema.py @@ -1,3 +1,5 @@ +from typing import List + import strawberry import strawberry_django from strawberry_django.pagination import OffsetPaginated @@ -5,6 +7,15 @@ from .types import * +@strawberry.type(name="Query") +class CoreQueryOld: + data_file: DataFileType = strawberry_django.field() + data_file_list: List[DataFileType] = strawberry_django.field() + + data_source: DataSourceType = strawberry_django.field() + data_source_list: List[DataSourceType] = strawberry_django.field() + + @strawberry.type(name="Query") class CoreQuery: data_file: DataFileType = strawberry_django.field() diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 5156fb1a696..7a07b7e600c 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -1,3 +1,5 @@ +from typing import List + import strawberry import strawberry_django from strawberry_django.pagination import OffsetPaginated @@ -5,6 +7,138 @@ from .types import * +@strawberry.type(name="Query") +class DCIMQueryOld: + cable: CableType = strawberry_django.field() + cable_list: List[CableType] = strawberry_django.field() + + console_port: ConsolePortType = strawberry_django.field() + console_port_list: List[ConsolePortType] = strawberry_django.field() + + console_port_template: ConsolePortTemplateType = strawberry_django.field() + console_port_template_list: List[ConsolePortTemplateType] = strawberry_django.field() + + console_server_port: ConsoleServerPortType = strawberry_django.field() + console_server_port_list: List[ConsoleServerPortType] = strawberry_django.field() + + console_server_port_template: ConsoleServerPortTemplateType = strawberry_django.field() + console_server_port_template_list: List[ConsoleServerPortTemplateType] = strawberry_django.field() + + device: DeviceType = strawberry_django.field() + device_list: List[DeviceType] = strawberry_django.field() + + device_bay: DeviceBayType = strawberry_django.field() + device_bay_list: List[DeviceBayType] = strawberry_django.field() + + device_bay_template: DeviceBayTemplateType = strawberry_django.field() + device_bay_template_list: List[DeviceBayTemplateType] = strawberry_django.field() + + device_role: DeviceRoleType = strawberry_django.field() + device_role_list: List[DeviceRoleType] = strawberry_django.field() + + device_type: DeviceTypeType = strawberry_django.field() + device_type_list: List[DeviceTypeType] = strawberry_django.field() + + front_port: FrontPortType = strawberry_django.field() + front_port_list: List[FrontPortType] = strawberry_django.field() + + front_port_template: FrontPortTemplateType = strawberry_django.field() + front_port_template_list: List[FrontPortTemplateType] = strawberry_django.field() + + mac_address: MACAddressType = strawberry_django.field() + mac_address_list: List[MACAddressType] = strawberry_django.field() + + interface: InterfaceType = strawberry_django.field() + interface_list: List[InterfaceType] = strawberry_django.field() + + interface_template: InterfaceTemplateType = strawberry_django.field() + interface_template_list: List[InterfaceTemplateType] = strawberry_django.field() + + inventory_item: InventoryItemType = strawberry_django.field() + inventory_item_list: List[InventoryItemType] = strawberry_django.field() + + inventory_item_role: InventoryItemRoleType = strawberry_django.field() + inventory_item_role_list: List[InventoryItemRoleType] = strawberry_django.field() + + inventory_item_template: InventoryItemTemplateType = strawberry_django.field() + inventory_item_template_list: List[InventoryItemTemplateType] = strawberry_django.field() + + location: LocationType = strawberry_django.field() + location_list: List[LocationType] = strawberry_django.field() + + manufacturer: ManufacturerType = strawberry_django.field() + manufacturer_list: List[ManufacturerType] = strawberry_django.field() + + module: ModuleType = strawberry_django.field() + module_list: List[ModuleType] = strawberry_django.field() + + module_bay: ModuleBayType = strawberry_django.field() + module_bay_list: List[ModuleBayType] = strawberry_django.field() + + module_bay_template: ModuleBayTemplateType = strawberry_django.field() + module_bay_template_list: List[ModuleBayTemplateType] = strawberry_django.field() + + module_type_profile: ModuleTypeProfileType = strawberry_django.field() + module_type_profile_list: List[ModuleTypeProfileType] = strawberry_django.field() + + module_type: ModuleTypeType = strawberry_django.field() + module_type_list: List[ModuleTypeType] = strawberry_django.field() + + platform: PlatformType = strawberry_django.field() + platform_list: List[PlatformType] = strawberry_django.field() + + power_feed: PowerFeedType = strawberry_django.field() + power_feed_list: List[PowerFeedType] = strawberry_django.field() + + power_outlet: PowerOutletType = strawberry_django.field() + power_outlet_list: List[PowerOutletType] = strawberry_django.field() + + power_outlet_template: PowerOutletTemplateType = strawberry_django.field() + power_outlet_template_list: List[PowerOutletTemplateType] = strawberry_django.field() + + power_panel: PowerPanelType = strawberry_django.field() + power_panel_list: List[PowerPanelType] = strawberry_django.field() + + power_port: PowerPortType = strawberry_django.field() + power_port_list: List[PowerPortType] = strawberry_django.field() + + power_port_template: PowerPortTemplateType = strawberry_django.field() + power_port_template_list: List[PowerPortTemplateType] = strawberry_django.field() + + rack_type: RackTypeType = strawberry_django.field() + rack_type_list: List[RackTypeType] = strawberry_django.field() + + rack: RackType = strawberry_django.field() + rack_list: List[RackType] = strawberry_django.field() + + rack_reservation: RackReservationType = strawberry_django.field() + rack_reservation_list: List[RackReservationType] = strawberry_django.field() + + rack_role: RackRoleType = strawberry_django.field() + rack_role_list: List[RackRoleType] = strawberry_django.field() + + rear_port: RearPortType = strawberry_django.field() + rear_port_list: List[RearPortType] = strawberry_django.field() + + rear_port_template: RearPortTemplateType = strawberry_django.field() + rear_port_template_list: List[RearPortTemplateType] = strawberry_django.field() + + region: RegionType = strawberry_django.field() + region_list: List[RegionType] = strawberry_django.field() + + site: SiteType = strawberry_django.field() + site_list: List[SiteType] = strawberry_django.field() + + site_group: SiteGroupType = strawberry_django.field() + site_group_list: List[SiteGroupType] = strawberry_django.field() + + virtual_chassis: VirtualChassisType = strawberry_django.field() + virtual_chassis_list: List[VirtualChassisType] = strawberry_django.field() + + virtual_device_context: VirtualDeviceContextType = strawberry_django.field() + virtual_device_context_list: List[VirtualDeviceContextType] = strawberry_django.field() + + @strawberry.type(name="Query") class DCIMQuery: cable: CableType = strawberry_django.field() diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index 9f7b29971e6..fe251bc5ec3 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -1,3 +1,5 @@ +from typing import List + import strawberry import strawberry_django from strawberry_django.pagination import OffsetPaginated @@ -5,6 +7,60 @@ from .types import * +@strawberry.type(name="Query") +class ExtrasQueryOld: + config_context: ConfigContextType = strawberry_django.field() + config_context_list: List[ConfigContextType] = strawberry_django.field() + + config_context_profile: ConfigContextProfileType = strawberry_django.field() + config_context_profile_list: List[ConfigContextProfileType] = strawberry_django.field() + + config_template: ConfigTemplateType = strawberry_django.field() + config_template_list: List[ConfigTemplateType] = strawberry_django.field() + + custom_field: CustomFieldType = strawberry_django.field() + custom_field_list: List[CustomFieldType] = strawberry_django.field() + + custom_field_choice_set: CustomFieldChoiceSetType = strawberry_django.field() + custom_field_choice_set_list: List[CustomFieldChoiceSetType] = strawberry_django.field() + + custom_link: CustomLinkType = strawberry_django.field() + custom_link_list: List[CustomLinkType] = strawberry_django.field() + + export_template: ExportTemplateType = strawberry_django.field() + export_template_list: List[ExportTemplateType] = strawberry_django.field() + + image_attachment: ImageAttachmentType = strawberry_django.field() + image_attachment_list: List[ImageAttachmentType] = strawberry_django.field() + + saved_filter: SavedFilterType = strawberry_django.field() + saved_filter_list: List[SavedFilterType] = strawberry_django.field() + + table_config: TableConfigType = strawberry_django.field() + table_config_list: List[TableConfigType] = strawberry_django.field() + + journal_entry: JournalEntryType = strawberry_django.field() + journal_entry_list: List[JournalEntryType] = strawberry_django.field() + + notification: NotificationType = strawberry_django.field() + notification_list: List[NotificationType] = strawberry_django.field() + + notification_group: NotificationGroupType = strawberry_django.field() + notification_group_list: List[NotificationGroupType] = strawberry_django.field() + + subscription: SubscriptionType = strawberry_django.field() + subscription_list: List[SubscriptionType] = strawberry_django.field() + + tag: TagType = strawberry_django.field() + tag_list: List[TagType] = strawberry_django.field() + + webhook: WebhookType = strawberry_django.field() + webhook_list: List[WebhookType] = strawberry_django.field() + + event_rule: EventRuleType = strawberry_django.field() + event_rule_list: List[EventRuleType] = strawberry_django.field() + + @strawberry.type(name="Query") class ExtrasQuery: config_context: ConfigContextType = strawberry_django.field() diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 82f2fe41e34..5185f700ed4 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -1,3 +1,5 @@ +from typing import List + import strawberry import strawberry_django from strawberry_django.pagination import OffsetPaginated @@ -5,6 +7,63 @@ from .types import * +@strawberry.type(name="Query") +class IPAMQueryOld: + asn: ASNType = strawberry_django.field() + asn_list: List[ASNType] = strawberry_django.field() + + asn_range: ASNRangeType = strawberry_django.field() + asn_range_list: List[ASNRangeType] = strawberry_django.field() + + aggregate: AggregateType = strawberry_django.field() + aggregate_list: List[AggregateType] = strawberry_django.field() + + ip_address: IPAddressType = strawberry_django.field() + ip_address_list: List[IPAddressType] = strawberry_django.field() + + ip_range: IPRangeType = strawberry_django.field() + ip_range_list: List[IPRangeType] = strawberry_django.field() + + prefix: PrefixType = strawberry_django.field() + prefix_list: List[PrefixType] = strawberry_django.field() + + rir: RIRType = strawberry_django.field() + rir_list: List[RIRType] = strawberry_django.field() + + role: RoleType = strawberry_django.field() + role_list: List[RoleType] = strawberry_django.field() + + route_target: RouteTargetType = strawberry_django.field() + route_target_list: List[RouteTargetType] = strawberry_django.field() + + service: ServiceType = strawberry_django.field() + service_list: List[ServiceType] = strawberry_django.field() + + service_template: ServiceTemplateType = strawberry_django.field() + service_template_list: List[ServiceTemplateType] = strawberry_django.field() + + fhrp_group: FHRPGroupType = strawberry_django.field() + fhrp_group_list: List[FHRPGroupType] = strawberry_django.field() + + fhrp_group_assignment: FHRPGroupAssignmentType = strawberry_django.field() + fhrp_group_assignment_list: List[FHRPGroupAssignmentType] = strawberry_django.field() + + vlan: VLANType = strawberry_django.field() + vlan_list: List[VLANType] = strawberry_django.field() + + vlan_group: VLANGroupType = strawberry_django.field() + vlan_group_list: List[VLANGroupType] = strawberry_django.field() + + vlan_translation_policy: VLANTranslationPolicyType = strawberry_django.field() + vlan_translation_policy_list: List[VLANTranslationPolicyType] = strawberry_django.field() + + vlan_translation_rule: VLANTranslationRuleType = strawberry_django.field() + vlan_translation_rule_list: List[VLANTranslationRuleType] = strawberry_django.field() + + vrf: VRFType = strawberry_django.field() + vrf_list: List[VRFType] = strawberry_django.field() + + @strawberry.type(name="Query") class IPAMQuery: asn: ASNType = strawberry_django.field() diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 70a6ec7bfa7..185167b8d52 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -4,17 +4,17 @@ from strawberry.extensions import MaxAliasesLimiter from strawberry.schema.config import StrawberryConfig -from circuits.graphql.schema import CircuitsQuery -from core.graphql.schema import CoreQuery -from dcim.graphql.schema import DCIMQuery -from extras.graphql.schema import ExtrasQuery -from ipam.graphql.schema import IPAMQuery +from circuits.graphql.schema import CircuitsQuery, CircuitsQueryOld +from core.graphql.schema import CoreQuery, CoreQueryOld +from dcim.graphql.schema import DCIMQuery, DCIMQueryOld +from extras.graphql.schema import ExtrasQuery, ExtrasQueryOld +from ipam.graphql.schema import IPAMQuery, IPAMQueryOld from netbox.registry import registry -from tenancy.graphql.schema import TenancyQuery -from users.graphql.schema import UsersQuery -from virtualization.graphql.schema import VirtualizationQuery -from vpn.graphql.schema import VPNQuery -from wireless.graphql.schema import WirelessQuery +from tenancy.graphql.schema import TenancyQuery, TenancyQueryOld +from users.graphql.schema import UsersQuery, UsersQueryOld +from virtualization.graphql.schema import VirtualizationQuery, VirtualizationQueryOld +from vpn.graphql.schema import VPNQuery, VPNQueryOld +from wireless.graphql.schema import WirelessQuery, WirelessQueryOld __all__ = ( 'Query', @@ -27,16 +27,16 @@ @strawberry.type class QueryV1( - UsersQuery, - CircuitsQuery, - CoreQuery, - DCIMQuery, - ExtrasQuery, - IPAMQuery, - TenancyQuery, - VirtualizationQuery, - VPNQuery, - WirelessQuery, + UsersQueryOld, + CircuitsQueryOld, + CoreQueryOld, + DCIMQueryOld, + ExtrasQueryOld, + IPAMQueryOld, + TenancyQueryOld, + VirtualizationQueryOld, + VPNQueryOld, + WirelessQueryOld, *registry['plugins']['graphql_schemas'], # Append plugin schemas ): """Query class for GraphQL API v1""" diff --git a/netbox/tenancy/graphql/schema.py b/netbox/tenancy/graphql/schema.py index 8138748733c..4bc826be3c6 100644 --- a/netbox/tenancy/graphql/schema.py +++ b/netbox/tenancy/graphql/schema.py @@ -1,3 +1,5 @@ +from typing import List + import strawberry import strawberry_django from strawberry_django.pagination import OffsetPaginated @@ -5,6 +7,27 @@ from .types import * +@strawberry.type(name="Query") +class TenancyQueryOld: + tenant: TenantType = strawberry_django.field() + tenant_list: List[TenantType] = strawberry_django.field() + + tenant_group: TenantGroupType = strawberry_django.field() + tenant_group_list: List[TenantGroupType] = strawberry_django.field() + + contact: ContactType = strawberry_django.field() + contact_list: List[ContactType] = strawberry_django.field() + + contact_role: ContactRoleType = strawberry_django.field() + contact_role_list: List[ContactRoleType] = strawberry_django.field() + + contact_group: ContactGroupType = strawberry_django.field() + contact_group_list: List[ContactGroupType] = strawberry_django.field() + + contact_assignment: ContactAssignmentType = strawberry_django.field() + contact_assignment_list: List[ContactAssignmentType] = strawberry_django.field() + + @strawberry.type(name="Query") class TenancyQuery: tenant: TenantType = strawberry_django.field() diff --git a/netbox/users/graphql/schema.py b/netbox/users/graphql/schema.py index a2b2b91eb7e..f9827b9054b 100644 --- a/netbox/users/graphql/schema.py +++ b/netbox/users/graphql/schema.py @@ -1,3 +1,5 @@ +from typing import List + import strawberry import strawberry_django from strawberry_django.pagination import OffsetPaginated @@ -5,6 +7,15 @@ from .types import * +@strawberry.type(name="Query") +class UsersQueryOld: + group: GroupType = strawberry_django.field() + group_list: List[GroupType] = strawberry_django.field() + + user: UserType = strawberry_django.field() + user_list: List[UserType] = strawberry_django.field() + + @strawberry.type(name="Query") class UsersQuery: group: GroupType = strawberry_django.field() diff --git a/netbox/virtualization/graphql/schema.py b/netbox/virtualization/graphql/schema.py index c052d61ee0c..2677c35492a 100644 --- a/netbox/virtualization/graphql/schema.py +++ b/netbox/virtualization/graphql/schema.py @@ -1,3 +1,5 @@ +from typing import List + import strawberry import strawberry_django from strawberry_django.pagination import OffsetPaginated @@ -5,6 +7,27 @@ from .types import * +@strawberry.type(name="Query") +class VirtualizationQueryOld: + cluster: ClusterType = strawberry_django.field() + cluster_list: List[ClusterType] = strawberry_django.field() + + cluster_group: ClusterGroupType = strawberry_django.field() + cluster_group_list: List[ClusterGroupType] = strawberry_django.field() + + cluster_type: ClusterTypeType = strawberry_django.field() + cluster_type_list: List[ClusterTypeType] = strawberry_django.field() + + virtual_machine: VirtualMachineType = strawberry_django.field() + virtual_machine_list: List[VirtualMachineType] = strawberry_django.field() + + vm_interface: VMInterfaceType = strawberry_django.field() + vm_interface_list: List[VMInterfaceType] = strawberry_django.field() + + virtual_disk: VirtualDiskType = strawberry_django.field() + virtual_disk_list: List[VirtualDiskType] = strawberry_django.field() + + @strawberry.type(name="Query") class VirtualizationQuery: cluster: ClusterType = strawberry_django.field() diff --git a/netbox/vpn/graphql/schema.py b/netbox/vpn/graphql/schema.py index 82159df0b3f..9e8e8979f0b 100644 --- a/netbox/vpn/graphql/schema.py +++ b/netbox/vpn/graphql/schema.py @@ -1,3 +1,5 @@ +from typing import List + import strawberry import strawberry_django from strawberry_django.pagination import OffsetPaginated @@ -5,6 +7,39 @@ from .types import * +@strawberry.type(name="Query") +class VPNQueryOld: + ike_policy: IKEPolicyType = strawberry_django.field() + ike_policy_list: List[IKEPolicyType] = strawberry_django.field() + + ike_proposal: IKEProposalType = strawberry_django.field() + ike_proposal_list: List[IKEProposalType] = strawberry_django.field() + + ipsec_policy: IPSecPolicyType = strawberry_django.field() + ipsec_policy_list: List[IPSecPolicyType] = strawberry_django.field() + + ipsec_profile: IPSecProfileType = strawberry_django.field() + ipsec_profile_list: List[IPSecProfileType] = strawberry_django.field() + + ipsec_proposal: IPSecProposalType = strawberry_django.field() + ipsec_proposal_list: List[IPSecProposalType] = strawberry_django.field() + + l2vpn: L2VPNType = strawberry_django.field() + l2vpn_list: List[L2VPNType] = strawberry_django.field() + + l2vpn_termination: L2VPNTerminationType = strawberry_django.field() + l2vpn_termination_list: List[L2VPNTerminationType] = strawberry_django.field() + + tunnel: TunnelType = strawberry_django.field() + tunnel_list: List[TunnelType] = strawberry_django.field() + + tunnel_group: TunnelGroupType = strawberry_django.field() + tunnel_group_list: List[TunnelGroupType] = strawberry_django.field() + + tunnel_termination: TunnelTerminationType = strawberry_django.field() + tunnel_termination_list: List[TunnelTerminationType] = strawberry_django.field() + + @strawberry.type(name="Query") class VPNQuery: ike_policy: IKEPolicyType = strawberry_django.field() diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py index 64b293a1313..7c88fdbec44 100644 --- a/netbox/wireless/graphql/schema.py +++ b/netbox/wireless/graphql/schema.py @@ -1,3 +1,5 @@ +from typing import List + import strawberry import strawberry_django from strawberry_django.pagination import OffsetPaginated @@ -5,6 +7,18 @@ from .types import * +@strawberry.type(name="Query") +class WirelessQueryOld: + wireless_lan: WirelessLANType = strawberry_django.field() + wireless_lan_list: List[WirelessLANType] = strawberry_django.field() + + wireless_lan_group: WirelessLANGroupType = strawberry_django.field() + wireless_lan_group_list: List[WirelessLANGroupType] = strawberry_django.field() + + wireless_link: WirelessLinkType = strawberry_django.field() + wireless_link_list: List[WirelessLinkType] = strawberry_django.field() + + @strawberry.type(name="Query") class WirelessQuery: wireless_lan: WirelessLANType = strawberry_django.field() From 810d1c24187e5bed0a40c29fafe2d31af8b383a7 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 21 Oct 2025 10:01:00 -0700 Subject: [PATCH 07/10] 19724 add the v2 to graphql testing --- netbox/netbox/tests/test_graphql.py | 86 ++++++++++++++++++++++++++++- netbox/utilities/testing/api.py | 4 +- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index 9fc7e7e9610..2b236fee404 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/netbox/netbox/tests/test_graphql.py @@ -45,10 +45,92 @@ def test_graphiql_interface(self): class GraphQLAPITestCase(APITestCase): + @override_settings(LOGIN_REQUIRED=True) + def test_graphql_filter_objects_v1(self): + """ + Test the operation of filters for GraphQL API v1 requests (old format with List[Type]). + """ + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + Location.objects.create( + site=sites[0], + name='Location 1', + slug='location-1', + status=LocationStatusChoices.STATUS_PLANNED + ), + Location.objects.create( + site=sites[1], + name='Location 2', + slug='location-2', + status=LocationStatusChoices.STATUS_STAGING + ), + Location.objects.create( + site=sites[1], + name='Location 3', + slug='location-3', + status=LocationStatusChoices.STATUS_ACTIVE + ), + + # Add object-level permission + obj_perm = ObjectPermission( + name='Test permission', + actions=['view'] + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(Location)) + obj_perm.object_types.add(ObjectType.objects.get_for_model(Site)) + + url = reverse('graphql_v1') + + # A valid request should return the filtered list + query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}' + response = self.client.post(url, data={'query': query}, format="json", **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + self.assertEqual(len(data['data']['location_list']), 1) + self.assertIsNotNone(data['data']['location_list'][0]['site']) + + # Test OR logic + query = """{ + location_list( filters: { + status: STATUS_PLANNED, + OR: {status: STATUS_STAGING} + }) { + id site {id} + } + }""" + response = self.client.post(url, data={'query': query}, format="json", **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + self.assertEqual(len(data['data']['location_list']), 2) + + # An invalid request should return an empty list + query = '{location_list(filters: {site_id: "99999"}) {id site {id}}}' # Invalid site ID + response = self.client.post(url, data={'query': query}, format="json", **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertEqual(len(data['data']['location_list']), 0) + + # Removing the permissions from location should result in an empty locations list + obj_perm.object_types.remove(ObjectType.objects.get_for_model(Location)) + query = '{site(id: ' + str(sites[0].pk) + ') {id locations {id}}}' + response = self.client.post(url, data={'query': query}, format="json", **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + self.assertEqual(len(data['data']['site']['locations']), 0) + @override_settings(LOGIN_REQUIRED=True) def test_graphql_filter_objects(self): """ - Test the operation of filters for GraphQL API requests. + Test the operation of filters for GraphQL API v2 requests (new format with OffsetPaginated). """ sites = ( Site(name='Site 1', slug='site-1'), @@ -85,7 +167,7 @@ def test_graphql_filter_objects(self): obj_perm.object_types.add(ObjectType.objects.get_for_model(Location)) obj_perm.object_types.add(ObjectType.objects.get_for_model(Site)) - url = reverse('graphql') + url = reverse('graphql_v2') # A valid request should return the filtered list query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {results {id site {id}} total_count}}' diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 29dcf8385e2..f97c194ef08 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -664,7 +664,7 @@ def test_graphql_get_object(self): @override_settings(LOGIN_REQUIRED=True) def test_graphql_list_objects(self): - url = reverse('graphql') + url = reverse('graphql_v2') field_name = f'{self._get_graphql_base_name()}_list' query = self._build_query(field_name) @@ -709,7 +709,7 @@ def test_graphql_filter_objects(self): if not hasattr(self, 'graphql_filter'): return - url = reverse('graphql') + url = reverse('graphql_v2') field_name = f'{self._get_graphql_base_name()}_list' query = self._build_filtered_query(field_name, **self.graphql_filter) From af55da008b52bdc294ebd52c6afa2b0cf139d8d8 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 21 Oct 2025 10:16:16 -0700 Subject: [PATCH 08/10] 19724 add the v2 to graphql testing --- netbox/utilities/testing/api.py | 167 +++++++++++++++++++++----------- 1 file changed, 112 insertions(+), 55 deletions(-) diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index f97c194ef08..3cdfd736252 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -515,10 +515,15 @@ def _get_graphql_base_name(self): base_name = self.model._meta.verbose_name.lower().replace(' ', '_') return getattr(self, 'graphql_base_name', base_name) - def _build_query_with_filter(self, name, filter_string): + def _build_query_with_filter(self, name, filter_string, api_version='v2'): """ Called by either _build_query or _build_filtered_query - construct the actual query given a name and filter string + + Args: + name: The query field name (e.g., 'device_list') + filter_string: Filter parameters string (e.g., '(filters: {id: "1"})') + api_version: 'v1' or 'v2' to determine response format """ type_class = get_graphql_type_for_model(self.model) @@ -564,16 +569,26 @@ def _build_query_with_filter(self, name, filter_string): # Check if this is a list query (ends with '_list') if name.endswith('_list'): - # Wrap fields in 'results' for paginated queries - query = f""" - {{ - {name}{filter_string} {{ - results {{ + if api_version == 'v2': + # v2: Wrap fields in 'results' for paginated queries + query = f""" + {{ + {name}{filter_string} {{ + results {{ + {fields_string} + }} + }} + }} + """ + else: + # v1: Return direct array (no 'results' wrapper) + query = f""" + {{ + {name}{filter_string} {{ {fields_string} }} }} - }} - """ + """ else: # Single object query (no pagination) query = f""" @@ -586,9 +601,14 @@ def _build_query_with_filter(self, name, filter_string): return query - def _build_filtered_query(self, name, **filters): + def _build_filtered_query(self, name, api_version='v2', **filters): """ Create a filtered query: i.e. device_list(filters: {name: {i_contains: "akron"}}){. + + Args: + name: The query field name + api_version: 'v1' or 'v2' to determine response format + **filters: Filter parameters """ # TODO: This should be extended to support AND, OR multi-lookups if filters: @@ -604,11 +624,16 @@ def _build_filtered_query(self, name, **filters): else: filter_string = '' - return self._build_query_with_filter(name, filter_string) + return self._build_query_with_filter(name, filter_string, api_version) - def _build_query(self, name, **filters): + def _build_query(self, name, api_version='v2', **filters): """ Create a normal query - unfiltered or with a string query: i.e. site(name: "aaa"){. + + Args: + name: The query field name + api_version: 'v1' or 'v2' to determine response format + **filters: Filter parameters """ if filters: filter_string = ', '.join(f'{k}:{v}' for k, v in filters.items()) @@ -616,7 +641,7 @@ def _build_query(self, name, **filters): else: filter_string = '' - return self._build_query_with_filter(name, filter_string) + return self._build_query_with_filter(name, filter_string, api_version) @override_settings(LOGIN_REQUIRED=True) def test_graphql_get_object(self): @@ -664,54 +689,71 @@ def test_graphql_get_object(self): @override_settings(LOGIN_REQUIRED=True) def test_graphql_list_objects(self): - url = reverse('graphql_v2') field_name = f'{self._get_graphql_base_name()}_list' - query = self._build_query(field_name) - - # Non-authenticated requests should fail - header = { - 'HTTP_ACCEPT': 'application/json', - } - with disable_warnings('django.request'): - response = self.client.post(url, data={'query': query}, format="json", **header) - self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN) - - # Add constrained permission - obj_perm = ObjectPermission( - name='Test permission', - actions=['view'], - constraints={'id': 0} # Impossible constraint - ) - obj_perm.save() - obj_perm.users.add(self.user) - obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) - # Request should succeed but return empty results list - response = self.client.post(url, data={'query': query}, format="json", **self.header) - self.assertHttpStatus(response, status.HTTP_200_OK) - data = json.loads(response.content) - self.assertNotIn('errors', data) - self.assertEqual(len(data['data'][field_name]['results']), 0) - - # Remove permission constraint - obj_perm.constraints = None - obj_perm.save() + # Test both GraphQL API versions + for api_version, url_name in [('v1', 'graphql_v1'), ('v2', 'graphql_v2')]: + with self.subTest(api_version=api_version): + url = reverse(url_name) + query = self._build_query(field_name, api_version=api_version) + + # Non-authenticated requests should fail + header = { + 'HTTP_ACCEPT': 'application/json', + } + with disable_warnings('django.request'): + response = self.client.post(url, data={'query': query}, format="json", **header) + self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN) + + # Add constrained permission + obj_perm = ObjectPermission( + name='Test permission', + actions=['view'], + constraints={'id': 0} # Impossible constraint + ) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + # Request should succeed but return empty results list + response = self.client.post(url, data={'query': query}, format="json", **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + + if api_version == 'v1': + # v1 returns direct array + self.assertEqual(len(data['data'][field_name]), 0) + else: + # v2 returns paginated response with results + self.assertEqual(len(data['data'][field_name]['results']), 0) + + # Remove permission constraint + obj_perm.constraints = None + obj_perm.save() + + # Request should return all objects + response = self.client.post(url, data={'query': query}, format="json", **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + + if api_version == 'v1': + # v1 returns direct array + self.assertEqual(len(data['data'][field_name]), self.model.objects.count()) + else: + # v2 returns paginated response with results + self.assertEqual(len(data['data'][field_name]['results']), self.model.objects.count()) - # Request should return all objects - response = self.client.post(url, data={'query': query}, format="json", **self.header) - self.assertHttpStatus(response, status.HTTP_200_OK) - data = json.loads(response.content) - self.assertNotIn('errors', data) - self.assertEqual(len(data['data'][field_name]['results']), self.model.objects.count()) + # Clean up permission for next iteration + obj_perm.delete() @override_settings(LOGIN_REQUIRED=True) def test_graphql_filter_objects(self): if not hasattr(self, 'graphql_filter'): return - url = reverse('graphql_v2') field_name = f'{self._get_graphql_base_name()}_list' - query = self._build_filtered_query(field_name, **self.graphql_filter) # Add object-level permission obj_perm = ObjectPermission( @@ -722,11 +764,26 @@ def test_graphql_filter_objects(self): obj_perm.users.add(self.user) obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) - response = self.client.post(url, data={'query': query}, format="json", **self.header) - self.assertHttpStatus(response, status.HTTP_200_OK) - data = json.loads(response.content) - self.assertNotIn('errors', data) - self.assertGreater(len(data['data'][field_name]['results']), 0) + # Test both GraphQL API versions + for api_version, url_name in [('v1', 'graphql_v1'), ('v2', 'graphql_v2')]: + with self.subTest(api_version=api_version): + url = reverse(url_name) + query = self._build_filtered_query(field_name, api_version=api_version, **self.graphql_filter) + + response = self.client.post(url, data={'query': query}, format="json", **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + data = json.loads(response.content) + self.assertNotIn('errors', data) + + if api_version == 'v1': + # v1 returns direct array + self.assertGreater(len(data['data'][field_name]), 0) + else: + # v2 returns paginated response with results + self.assertGreater(len(data['data'][field_name]['results']), 0) + + # Clean up permission + obj_perm.delete() class APIViewTestCase( GetObjectViewTestCase, From 26c91f01c6f64db3fbd8a19e87e803bff0efbf65 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 21 Oct 2025 10:27:10 -0700 Subject: [PATCH 09/10] 19724 update docs --- docs/integrations/graphql-api.md | 106 ++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/docs/integrations/graphql-api.md b/docs/integrations/graphql-api.md index 78d86f11c18..b7f64639e9d 100644 --- a/docs/integrations/graphql-api.md +++ b/docs/integrations/graphql-api.md @@ -16,6 +16,28 @@ http://netbox/graphql/ \ The response will include the requested data formatted as JSON: +```json +{ + "data": { + "circuits": [ + { + "cid": "1002840283", + "provider": { + "name": "CenturyLink" + } + }, + { + "cid": "1002840457", + "provider": { + "name": "CenturyLink" + } + } + ] + } +} +``` +If using the GraphQL API v2 the format will be: + ```json { "data": { @@ -50,17 +72,30 @@ NetBox provides both a singular and plural query field for each object type: For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices. !!! note "Changed in NetBox v4.5" - List queries now return paginated results. The actual objects are contained within the `results` field of the response, along with `total_count` and `page_info` fields for pagination metadata. Prior to v4.5, list queries returned objects directly as an array. + If using the GraphQL API v2, List queries now return paginated results. The actual objects are contained within the `results` field of the response, along with `total_count` and `page_info` fields for pagination metadata. Prior to v4.5, list queries returned objects directly as an array. For more detail on constructing GraphQL queries, see the [GraphQL queries documentation](https://graphql.org/learn/queries/). For filtering and lookup syntax, please refer to the [Strawberry Django documentation](https://strawberry.rocks/docs/django/guide/filters). ## Filtering !!! note "Changed in NetBox v4.3" - The filtering syntax fo the GraphQL API has changed substantially in NetBox v4.3. + The filtering syntax for the GraphQL API has changed substantially in NetBox v4.3. Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only active sites: +``` +query { + site_list( + filters: { + status: STATUS_ACTIVE + } + ) { + name + } +} +``` +If using the GraphQL API v2 the format will be: + ``` query { site_list( @@ -77,6 +112,26 @@ query { Filters can be combined with logical operators, such as `OR` and `NOT`. For example, the following will return every site that is planned _or_ assigned to a tenant named Foo: +``` +query { + site_list( + filters: { + status: STATUS_PLANNED, + OR: { + tenant: { + name: { + exact: "Foo" + } + } + } + } + ) { + name + } +} +``` +If using the GraphQL API v2 the format will be: + ``` query { site_list( @@ -100,6 +155,19 @@ query { Filtering can also be applied to related objects. For example, the following query will return only enabled interfaces for each device: +``` +query { + device_list { + id + name + interfaces(filters: {enabled: true}) { + name + } + } +} +``` +If using the GraphQL API v2 the format will be: + ``` query { device_list { @@ -118,6 +186,29 @@ query { Certain queries can return multiple types of objects, for example cable terminations can return circuit terminations, console ports and many others. These can be queried using [inline fragments](https://graphql.org/learn/schema/#union-types) as shown below: +``` +{ + cable_list { + id + a_terminations { + ... on CircuitTerminationType { + id + class_type + } + ... on ConsolePortType { + id + class_type + } + ... on ConsoleServerPortType { + id + class_type + } + } + } +} +``` +If using the GraphQL API v2 the format will be: + ``` { cable_list { @@ -146,6 +237,17 @@ The field "class_type" is an easy way to distinguish what type of object it is w ## Pagination +Queries can be paginated by specifying pagination in the query and supplying an offset and optionaly a limit in the query. If no limit is given, a default of 100 is used. Queries are not paginated unless requested in the query. An example paginated query is shown below: + +``` +query { + device_list(pagination: { offset: 0, limit: 20 }) { + id + } +} +``` +### Pagination in GraphQL API V2 + All list queries return paginated results using the `OffsetPaginated` type, which includes: - `results`: The list of objects matching the query From 76caae12fa44e2810c598515d232b1d7ef3b0ba5 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 22 Oct 2025 08:57:36 -0700 Subject: [PATCH 10/10] 19724 change from old to V1 --- netbox/circuits/graphql/schema.py | 2 +- netbox/core/graphql/schema.py | 2 +- netbox/dcim/graphql/schema.py | 2 +- netbox/extras/graphql/schema.py | 2 +- netbox/ipam/graphql/schema.py | 2 +- netbox/netbox/graphql/schema.py | 40 ++++++++++++------------- netbox/tenancy/graphql/schema.py | 2 +- netbox/users/graphql/schema.py | 2 +- netbox/virtualization/graphql/schema.py | 2 +- netbox/vpn/graphql/schema.py | 2 +- netbox/wireless/graphql/schema.py | 2 +- 11 files changed, 30 insertions(+), 30 deletions(-) diff --git a/netbox/circuits/graphql/schema.py b/netbox/circuits/graphql/schema.py index 3cf2cd52e9b..5d28a9cbf45 100644 --- a/netbox/circuits/graphql/schema.py +++ b/netbox/circuits/graphql/schema.py @@ -8,7 +8,7 @@ @strawberry.type(name="Query") -class CircuitsQueryOld: +class CircuitsQueryV1: circuit: CircuitType = strawberry_django.field() circuit_list: List[CircuitType] = strawberry_django.field() diff --git a/netbox/core/graphql/schema.py b/netbox/core/graphql/schema.py index d82944e2978..a10b2c457c0 100644 --- a/netbox/core/graphql/schema.py +++ b/netbox/core/graphql/schema.py @@ -8,7 +8,7 @@ @strawberry.type(name="Query") -class CoreQueryOld: +class CoreQueryV1: data_file: DataFileType = strawberry_django.field() data_file_list: List[DataFileType] = strawberry_django.field() diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 7a07b7e600c..d4291453322 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -8,7 +8,7 @@ @strawberry.type(name="Query") -class DCIMQueryOld: +class DCIMQueryV1: cable: CableType = strawberry_django.field() cable_list: List[CableType] = strawberry_django.field() diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index fe251bc5ec3..992a025b36c 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -8,7 +8,7 @@ @strawberry.type(name="Query") -class ExtrasQueryOld: +class ExtrasQueryV1: config_context: ConfigContextType = strawberry_django.field() config_context_list: List[ConfigContextType] = strawberry_django.field() diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 5185f700ed4..8a372b25f25 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -8,7 +8,7 @@ @strawberry.type(name="Query") -class IPAMQueryOld: +class IPAMQueryV1: asn: ASNType = strawberry_django.field() asn_list: List[ASNType] = strawberry_django.field() diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 185167b8d52..3fda33d5925 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -4,17 +4,17 @@ from strawberry.extensions import MaxAliasesLimiter from strawberry.schema.config import StrawberryConfig -from circuits.graphql.schema import CircuitsQuery, CircuitsQueryOld -from core.graphql.schema import CoreQuery, CoreQueryOld -from dcim.graphql.schema import DCIMQuery, DCIMQueryOld -from extras.graphql.schema import ExtrasQuery, ExtrasQueryOld -from ipam.graphql.schema import IPAMQuery, IPAMQueryOld +from circuits.graphql.schema import CircuitsQuery, CircuitsQueryV1 +from core.graphql.schema import CoreQuery, CoreQueryV1 +from dcim.graphql.schema import DCIMQuery, DCIMQueryV1 +from extras.graphql.schema import ExtrasQuery, ExtrasQueryV1 +from ipam.graphql.schema import IPAMQuery, IPAMQueryV1 from netbox.registry import registry -from tenancy.graphql.schema import TenancyQuery, TenancyQueryOld -from users.graphql.schema import UsersQuery, UsersQueryOld -from virtualization.graphql.schema import VirtualizationQuery, VirtualizationQueryOld -from vpn.graphql.schema import VPNQuery, VPNQueryOld -from wireless.graphql.schema import WirelessQuery, WirelessQueryOld +from tenancy.graphql.schema import TenancyQuery, TenancyQueryV1 +from users.graphql.schema import UsersQuery, UsersQueryV1 +from virtualization.graphql.schema import VirtualizationQuery, VirtualizationQueryV1 +from vpn.graphql.schema import VPNQuery, VPNQueryV1 +from wireless.graphql.schema import WirelessQuery, WirelessQueryV1 __all__ = ( 'Query', @@ -27,16 +27,16 @@ @strawberry.type class QueryV1( - UsersQueryOld, - CircuitsQueryOld, - CoreQueryOld, - DCIMQueryOld, - ExtrasQueryOld, - IPAMQueryOld, - TenancyQueryOld, - VirtualizationQueryOld, - VPNQueryOld, - WirelessQueryOld, + UsersQueryV1, + CircuitsQueryV1, + CoreQueryV1, + DCIMQueryV1, + ExtrasQueryV1, + IPAMQueryV1, + TenancyQueryV1, + VirtualizationQueryV1, + VPNQueryV1, + WirelessQueryV1, *registry['plugins']['graphql_schemas'], # Append plugin schemas ): """Query class for GraphQL API v1""" diff --git a/netbox/tenancy/graphql/schema.py b/netbox/tenancy/graphql/schema.py index 4bc826be3c6..84c174922f3 100644 --- a/netbox/tenancy/graphql/schema.py +++ b/netbox/tenancy/graphql/schema.py @@ -8,7 +8,7 @@ @strawberry.type(name="Query") -class TenancyQueryOld: +class TenancyQueryV1: tenant: TenantType = strawberry_django.field() tenant_list: List[TenantType] = strawberry_django.field() diff --git a/netbox/users/graphql/schema.py b/netbox/users/graphql/schema.py index f9827b9054b..7175570c820 100644 --- a/netbox/users/graphql/schema.py +++ b/netbox/users/graphql/schema.py @@ -8,7 +8,7 @@ @strawberry.type(name="Query") -class UsersQueryOld: +class UsersQueryV1: group: GroupType = strawberry_django.field() group_list: List[GroupType] = strawberry_django.field() diff --git a/netbox/virtualization/graphql/schema.py b/netbox/virtualization/graphql/schema.py index 2677c35492a..86991bd51a0 100644 --- a/netbox/virtualization/graphql/schema.py +++ b/netbox/virtualization/graphql/schema.py @@ -8,7 +8,7 @@ @strawberry.type(name="Query") -class VirtualizationQueryOld: +class VirtualizationQueryV1: cluster: ClusterType = strawberry_django.field() cluster_list: List[ClusterType] = strawberry_django.field() diff --git a/netbox/vpn/graphql/schema.py b/netbox/vpn/graphql/schema.py index 9e8e8979f0b..1dbe911c60a 100644 --- a/netbox/vpn/graphql/schema.py +++ b/netbox/vpn/graphql/schema.py @@ -8,7 +8,7 @@ @strawberry.type(name="Query") -class VPNQueryOld: +class VPNQueryV1: ike_policy: IKEPolicyType = strawberry_django.field() ike_policy_list: List[IKEPolicyType] = strawberry_django.field() diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py index 7c88fdbec44..f569850faad 100644 --- a/netbox/wireless/graphql/schema.py +++ b/netbox/wireless/graphql/schema.py @@ -8,7 +8,7 @@ @strawberry.type(name="Query") -class WirelessQueryOld: +class WirelessQueryV1: wireless_lan: WirelessLANType = strawberry_django.field() wireless_lan_list: List[WirelessLANType] = strawberry_django.field()