Compare commits

...

11 Commits

Author SHA1 Message Date
Arthur
76caae12fa 19724 change from old to V1
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
2025-10-22 08:57:36 -07:00
Arthur
26c91f01c6 19724 update docs
Some checks are pending
CI / build (20.x, 3.12) (push) Waiting to run
CI / build (20.x, 3.13) (push) Waiting to run
2025-10-21 10:27:10 -07:00
Arthur
af55da008b 19724 add the v2 to graphql testing 2025-10-21 10:16:16 -07:00
Arthur
810d1c2418 19724 add the v2 to graphql testing 2025-10-21 10:01:00 -07:00
Arthur
91b2d61ea4 19724 Use v2 API for new pagination queries
Some checks are pending
CI / build (20.x, 3.12) (push) Waiting to run
CI / build (20.x, 3.13) (push) Waiting to run
2025-10-20 16:52:46 -07:00
Arthur
b7b7b00885 Merge branch 'feature' into 19724-graphql 2025-10-20 16:29:32 -07:00
Arthur
595b343cd0 19724 add doc note 2025-10-14 14:38:16 -07:00
Arthur
730aee9b26 19724 fix doc query 2025-10-14 14:15:16 -07:00
Arthur
8aa1e2802b 19724 fix tests 2025-10-14 14:06:15 -07:00
Arthur
c2d19119cb 19724 update documentation 2025-10-14 13:54:58 -07:00
Arthur
0c4d0fa2e8 19724 pagingate graphql queries 2025-10-14 13:46:30 -07:00
14 changed files with 788 additions and 86 deletions

View File

@@ -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:
@@ -36,6 +36,30 @@ The response will include the requested data formatted as JSON:
}
}
```
If using the GraphQL API v2 the format will be:
```json
{
"data": {
"circuit_list": {
"results": [
{
"cid": "1002840283",
"provider": {
"name": "CenturyLink"
}
},
{
"cid": "1002840457",
"provider": {
"name": "CenturyLink"
}
}
]
}
}
}
```
!!! note
It's recommended to pass the return data through a JSON parser such as `jq` for better readability.
@@ -47,12 +71,15 @@ 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"
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:
@@ -67,6 +94,21 @@ query {
}
}
```
If using the GraphQL API v2 the format will be:
```
query {
site_list(
filters: {
status: STATUS_ACTIVE
}
) {
results {
name
}
}
}
```
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:
@@ -88,6 +130,28 @@ query {
}
}
```
If using the GraphQL API v2 the format will be:
```
query {
site_list(
filters: {
status: STATUS_PLANNED,
OR: {
tenant: {
name: {
exact: "Foo"
}
}
}
}
) {
results {
name
}
}
}
```
Filtering can also be applied to related objects. For example, the following query will return only enabled interfaces for each device:
@@ -102,6 +166,21 @@ query {
}
}
```
If using the GraphQL API v2 the format will be:
```
query {
device_list {
results {
id
name
interfaces(filters: {enabled: {exact: true}}) {
name
}
}
}
}
```
## Multiple Return Types
@@ -128,6 +207,31 @@ Certain queries can return multiple types of objects, for example cable terminat
}
}
```
If using the GraphQL API v2 the format will be:
```
{
cable_list {
results {
id
a_terminations {
... on CircuitTerminationType {
id
class_type
}
... on ConsolePortType {
id
class_type
}
... on ConsoleServerPortType {
id
class_type
}
}
}
}
}
```
The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort".
@@ -142,6 +246,47 @@ query {
}
}
```
### 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
- `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 }) {
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

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class CircuitsQuery:
class CircuitsQueryV1:
circuit: CircuitType = strawberry_django.field()
circuit_list: List[CircuitType] = strawberry_django.field()
@@ -40,3 +41,41 @@ class CircuitsQuery:
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()
circuit_list: OffsetPaginated[CircuitType] = strawberry_django.offset_paginated()
circuit_termination: CircuitTerminationType = strawberry_django.field()
circuit_termination_list: OffsetPaginated[CircuitTerminationType] = strawberry_django.offset_paginated()
circuit_type: CircuitTypeType = strawberry_django.field()
circuit_type_list: OffsetPaginated[CircuitTypeType] = strawberry_django.offset_paginated()
circuit_group: CircuitGroupType = strawberry_django.field()
circuit_group_list: OffsetPaginated[CircuitGroupType] = strawberry_django.offset_paginated()
circuit_group_assignment: CircuitGroupAssignmentType = strawberry_django.field()
circuit_group_assignment_list: OffsetPaginated[CircuitGroupAssignmentType] = strawberry_django.offset_paginated()
provider: ProviderType = strawberry_django.field()
provider_list: OffsetPaginated[ProviderType] = strawberry_django.offset_paginated()
provider_account: ProviderAccountType = strawberry_django.field()
provider_account_list: OffsetPaginated[ProviderAccountType] = strawberry_django.offset_paginated()
provider_network: ProviderNetworkType = strawberry_django.field()
provider_network_list: OffsetPaginated[ProviderNetworkType] = strawberry_django.offset_paginated()
virtual_circuit: VirtualCircuitType = strawberry_django.field()
virtual_circuit_list: OffsetPaginated[VirtualCircuitType] = strawberry_django.offset_paginated()
virtual_circuit_termination: 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: OffsetPaginated[VirtualCircuitTypeType] = strawberry_django.offset_paginated()

View File

@@ -2,14 +2,24 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class CoreQuery:
class CoreQueryV1:
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()
data_file_list: OffsetPaginated[DataFileType] = strawberry_django.offset_paginated()
data_source: DataSourceType = strawberry_django.field()
data_source_list: OffsetPaginated[DataSourceType] = strawberry_django.offset_paginated()

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class DCIMQuery:
class DCIMQueryV1:
cable: CableType = strawberry_django.field()
cable_list: List[CableType] = strawberry_django.field()
@@ -136,3 +137,137 @@ class DCIMQuery:
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()
cable_list: OffsetPaginated[CableType] = strawberry_django.offset_paginated()
console_port: ConsolePortType = strawberry_django.field()
console_port_list: OffsetPaginated[ConsolePortType] = strawberry_django.offset_paginated()
console_port_template: 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: OffsetPaginated[ConsoleServerPortType] = strawberry_django.offset_paginated()
console_server_port_template: ConsoleServerPortTemplateType = strawberry_django.field()
console_server_port_template_list: OffsetPaginated[ConsoleServerPortTemplateType] = (
strawberry_django.offset_paginated()
)
device: DeviceType = strawberry_django.field()
device_list: OffsetPaginated[DeviceType] = strawberry_django.offset_paginated()
device_bay: DeviceBayType = strawberry_django.field()
device_bay_list: OffsetPaginated[DeviceBayType] = strawberry_django.offset_paginated()
device_bay_template: DeviceBayTemplateType = strawberry_django.field()
device_bay_template_list: OffsetPaginated[DeviceBayTemplateType] = strawberry_django.offset_paginated()
device_role: DeviceRoleType = strawberry_django.field()
device_role_list: OffsetPaginated[DeviceRoleType] = strawberry_django.offset_paginated()
device_type: DeviceTypeType = strawberry_django.field()
device_type_list: OffsetPaginated[DeviceTypeType] = strawberry_django.offset_paginated()
front_port: FrontPortType = strawberry_django.field()
front_port_list: OffsetPaginated[FrontPortType] = strawberry_django.offset_paginated()
front_port_template: FrontPortTemplateType = strawberry_django.field()
front_port_template_list: OffsetPaginated[FrontPortTemplateType] = strawberry_django.offset_paginated()
mac_address: MACAddressType = strawberry_django.field()
mac_address_list: OffsetPaginated[MACAddressType] = strawberry_django.offset_paginated()
interface: InterfaceType = strawberry_django.field()
interface_list: OffsetPaginated[InterfaceType] = strawberry_django.offset_paginated()
interface_template: InterfaceTemplateType = strawberry_django.field()
interface_template_list: OffsetPaginated[InterfaceTemplateType] = strawberry_django.offset_paginated()
inventory_item: InventoryItemType = strawberry_django.field()
inventory_item_list: OffsetPaginated[InventoryItemType] = strawberry_django.offset_paginated()
inventory_item_role: 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: OffsetPaginated[InventoryItemTemplateType] = strawberry_django.offset_paginated()
location: LocationType = strawberry_django.field()
location_list: OffsetPaginated[LocationType] = strawberry_django.offset_paginated()
manufacturer: ManufacturerType = strawberry_django.field()
manufacturer_list: OffsetPaginated[ManufacturerType] = strawberry_django.offset_paginated()
module: ModuleType = strawberry_django.field()
module_list: OffsetPaginated[ModuleType] = strawberry_django.offset_paginated()
module_bay: ModuleBayType = strawberry_django.field()
module_bay_list: OffsetPaginated[ModuleBayType] = strawberry_django.offset_paginated()
module_bay_template: 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: OffsetPaginated[ModuleTypeProfileType] = strawberry_django.offset_paginated()
module_type: ModuleTypeType = strawberry_django.field()
module_type_list: OffsetPaginated[ModuleTypeType] = strawberry_django.offset_paginated()
platform: PlatformType = strawberry_django.field()
platform_list: OffsetPaginated[PlatformType] = strawberry_django.offset_paginated()
power_feed: PowerFeedType = strawberry_django.field()
power_feed_list: OffsetPaginated[PowerFeedType] = strawberry_django.offset_paginated()
power_outlet: PowerOutletType = strawberry_django.field()
power_outlet_list: OffsetPaginated[PowerOutletType] = strawberry_django.offset_paginated()
power_outlet_template: PowerOutletTemplateType = strawberry_django.field()
power_outlet_template_list: OffsetPaginated[PowerOutletTemplateType] = strawberry_django.offset_paginated()
power_panel: PowerPanelType = strawberry_django.field()
power_panel_list: OffsetPaginated[PowerPanelType] = strawberry_django.offset_paginated()
power_port: PowerPortType = strawberry_django.field()
power_port_list: OffsetPaginated[PowerPortType] = strawberry_django.offset_paginated()
power_port_template: PowerPortTemplateType = strawberry_django.field()
power_port_template_list: OffsetPaginated[PowerPortTemplateType] = strawberry_django.offset_paginated()
rack_type: RackTypeType = strawberry_django.field()
rack_type_list: OffsetPaginated[RackTypeType] = strawberry_django.offset_paginated()
rack: RackType = strawberry_django.field()
rack_list: OffsetPaginated[RackType] = strawberry_django.offset_paginated()
rack_reservation: RackReservationType = strawberry_django.field()
rack_reservation_list: OffsetPaginated[RackReservationType] = strawberry_django.offset_paginated()
rack_role: RackRoleType = strawberry_django.field()
rack_role_list: OffsetPaginated[RackRoleType] = strawberry_django.offset_paginated()
rear_port: RearPortType = strawberry_django.field()
rear_port_list: OffsetPaginated[RearPortType] = strawberry_django.offset_paginated()
rear_port_template: RearPortTemplateType = strawberry_django.field()
rear_port_template_list: OffsetPaginated[RearPortTemplateType] = strawberry_django.offset_paginated()
region: RegionType = strawberry_django.field()
region_list: OffsetPaginated[RegionType] = strawberry_django.offset_paginated()
site: SiteType = strawberry_django.field()
site_list: OffsetPaginated[SiteType] = strawberry_django.offset_paginated()
site_group: SiteGroupType = strawberry_django.field()
site_group_list: OffsetPaginated[SiteGroupType] = strawberry_django.offset_paginated()
virtual_chassis: VirtualChassisType = strawberry_django.field()
virtual_chassis_list: OffsetPaginated[VirtualChassisType] = strawberry_django.offset_paginated()
virtual_device_context: VirtualDeviceContextType = strawberry_django.field()
virtual_device_context_list: OffsetPaginated[VirtualDeviceContextType] = strawberry_django.offset_paginated()

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class ExtrasQuery:
class ExtrasQueryV1:
config_context: ConfigContextType = strawberry_django.field()
config_context_list: List[ConfigContextType] = strawberry_django.field()
@@ -58,3 +59,57 @@ class ExtrasQuery:
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()
config_context_list: OffsetPaginated[ConfigContextType] = strawberry_django.offset_paginated()
config_context_profile: ConfigContextProfileType = strawberry_django.field()
config_context_profile_list: OffsetPaginated[ConfigContextProfileType] = strawberry_django.offset_paginated()
config_template: ConfigTemplateType = strawberry_django.field()
config_template_list: OffsetPaginated[ConfigTemplateType] = strawberry_django.offset_paginated()
custom_field: 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: OffsetPaginated[CustomFieldChoiceSetType] = strawberry_django.offset_paginated()
custom_link: CustomLinkType = strawberry_django.field()
custom_link_list: OffsetPaginated[CustomLinkType] = strawberry_django.offset_paginated()
export_template: ExportTemplateType = strawberry_django.field()
export_template_list: OffsetPaginated[ExportTemplateType] = strawberry_django.offset_paginated()
image_attachment: ImageAttachmentType = strawberry_django.field()
image_attachment_list: OffsetPaginated[ImageAttachmentType] = strawberry_django.offset_paginated()
saved_filter: SavedFilterType = strawberry_django.field()
saved_filter_list: OffsetPaginated[SavedFilterType] = strawberry_django.offset_paginated()
table_config: TableConfigType = strawberry_django.field()
table_config_list: OffsetPaginated[TableConfigType] = strawberry_django.offset_paginated()
journal_entry: JournalEntryType = strawberry_django.field()
journal_entry_list: OffsetPaginated[JournalEntryType] = strawberry_django.offset_paginated()
notification: NotificationType = strawberry_django.field()
notification_list: OffsetPaginated[NotificationType] = strawberry_django.offset_paginated()
notification_group: NotificationGroupType = strawberry_django.field()
notification_group_list: OffsetPaginated[NotificationGroupType] = strawberry_django.offset_paginated()
subscription: SubscriptionType = strawberry_django.field()
subscription_list: OffsetPaginated[SubscriptionType] = strawberry_django.offset_paginated()
tag: TagType = strawberry_django.field()
tag_list: OffsetPaginated[TagType] = strawberry_django.offset_paginated()
webhook: WebhookType = strawberry_django.field()
webhook_list: OffsetPaginated[WebhookType] = strawberry_django.offset_paginated()
event_rule: EventRuleType = strawberry_django.field()
event_rule_list: OffsetPaginated[EventRuleType] = strawberry_django.offset_paginated()

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class IPAMQuery:
class IPAMQueryV1:
asn: ASNType = strawberry_django.field()
asn_list: List[ASNType] = strawberry_django.field()
@@ -61,3 +62,60 @@ class IPAMQuery:
vrf: VRFType = strawberry_django.field()
vrf_list: List[VRFType] = strawberry_django.field()
@strawberry.type(name="Query")
class IPAMQuery:
asn: ASNType = strawberry_django.field()
asn_list: OffsetPaginated[ASNType] = strawberry_django.offset_paginated()
asn_range: ASNRangeType = strawberry_django.field()
asn_range_list: OffsetPaginated[ASNRangeType] = strawberry_django.offset_paginated()
aggregate: AggregateType = strawberry_django.field()
aggregate_list: OffsetPaginated[AggregateType] = strawberry_django.offset_paginated()
ip_address: IPAddressType = strawberry_django.field()
ip_address_list: OffsetPaginated[IPAddressType] = strawberry_django.offset_paginated()
ip_range: IPRangeType = strawberry_django.field()
ip_range_list: OffsetPaginated[IPRangeType] = strawberry_django.offset_paginated()
prefix: PrefixType = strawberry_django.field()
prefix_list: OffsetPaginated[PrefixType] = strawberry_django.offset_paginated()
rir: RIRType = strawberry_django.field()
rir_list: OffsetPaginated[RIRType] = strawberry_django.offset_paginated()
role: RoleType = strawberry_django.field()
role_list: OffsetPaginated[RoleType] = strawberry_django.offset_paginated()
route_target: RouteTargetType = strawberry_django.field()
route_target_list: OffsetPaginated[RouteTargetType] = strawberry_django.offset_paginated()
service: ServiceType = strawberry_django.field()
service_list: OffsetPaginated[ServiceType] = strawberry_django.offset_paginated()
service_template: ServiceTemplateType = strawberry_django.field()
service_template_list: OffsetPaginated[ServiceTemplateType] = strawberry_django.offset_paginated()
fhrp_group: FHRPGroupType = strawberry_django.field()
fhrp_group_list: OffsetPaginated[FHRPGroupType] = strawberry_django.offset_paginated()
fhrp_group_assignment: FHRPGroupAssignmentType = strawberry_django.field()
fhrp_group_assignment_list: OffsetPaginated[FHRPGroupAssignmentType] = strawberry_django.offset_paginated()
vlan: VLANType = strawberry_django.field()
vlan_list: OffsetPaginated[VLANType] = strawberry_django.offset_paginated()
vlan_group: VLANGroupType = strawberry_django.field()
vlan_group_list: OffsetPaginated[VLANGroupType] = strawberry_django.offset_paginated()
vlan_translation_policy: 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: OffsetPaginated[VLANTranslationRuleType] = strawberry_django.offset_paginated()
vrf: VRFType = strawberry_django.field()
vrf_list: OffsetPaginated[VRFType] = strawberry_django.offset_paginated()

View File

@@ -4,17 +4,17 @@ from strawberry_django.optimizer import DjangoOptimizerExtension
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, 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
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, 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 @@ __all__ = (
@strawberry.type
class QueryV1(
UsersQuery,
CircuitsQuery,
CoreQuery,
DCIMQuery,
ExtrasQuery,
IPAMQuery,
TenancyQuery,
VirtualizationQuery,
VPNQuery,
WirelessQuery,
UsersQueryV1,
CircuitsQueryV1,
CoreQueryV1,
DCIMQueryV1,
ExtrasQueryV1,
IPAMQueryV1,
TenancyQueryV1,
VirtualizationQueryV1,
VPNQueryV1,
WirelessQueryV1,
*registry['plugins']['graphql_schemas'], # Append plugin schemas
):
"""Query class for GraphQL API v1"""

View File

@@ -46,9 +46,9 @@ class GraphQLTestCase(TestCase):
class GraphQLAPITestCase(APITestCase):
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_filter_objects(self):
def test_graphql_filter_objects_v1(self):
"""
Test the operation of filters for GraphQL API requests.
Test the operation of filters for GraphQL API v1 requests (old format with List[Type]).
"""
sites = (
Site(name='Site 1', slug='site-1'),
@@ -85,7 +85,7 @@ class GraphQLAPITestCase(APITestCase):
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_v1')
# A valid request should return the filtered list
query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}'
@@ -126,3 +126,91 @@ class GraphQLAPITestCase(APITestCase):
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 v2 requests (new format with OffsetPaginated).
"""
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_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}}'
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']['results']), 1)
self.assertEqual(data['data']['location_list']['total_count'], 1)
self.assertIsNotNone(data['data']['location_list']['results'][0]['site'])
# Test OR logic
query = """{
location_list( filters: {
status: STATUS_PLANNED,
OR: {status: STATUS_STAGING}
}) {
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']['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"}) {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']['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))
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)

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class TenancyQuery:
class TenancyQueryV1:
tenant: TenantType = strawberry_django.field()
tenant_list: List[TenantType] = strawberry_django.field()
@@ -25,3 +26,24 @@ class TenancyQuery:
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()
tenant_list: OffsetPaginated[TenantType] = strawberry_django.offset_paginated()
tenant_group: TenantGroupType = strawberry_django.field()
tenant_group_list: OffsetPaginated[TenantGroupType] = strawberry_django.offset_paginated()
contact: ContactType = strawberry_django.field()
contact_list: OffsetPaginated[ContactType] = strawberry_django.offset_paginated()
contact_role: ContactRoleType = strawberry_django.field()
contact_role_list: OffsetPaginated[ContactRoleType] = strawberry_django.offset_paginated()
contact_group: ContactGroupType = strawberry_django.field()
contact_group_list: OffsetPaginated[ContactGroupType] = strawberry_django.offset_paginated()
contact_assignment: ContactAssignmentType = strawberry_django.field()
contact_assignment_list: OffsetPaginated[ContactAssignmentType] = strawberry_django.offset_paginated()

View File

@@ -2,14 +2,24 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class UsersQuery:
class UsersQueryV1:
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()
group_list: OffsetPaginated[GroupType] = strawberry_django.offset_paginated()
user: UserType = strawberry_django.field()
user_list: OffsetPaginated[UserType] = strawberry_django.offset_paginated()

View File

@@ -515,10 +515,15 @@ class APIViewTestCases:
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)
@@ -562,19 +567,48 @@ class APIViewTestCases:
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'):
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"""
{{
{name}{filter_string} {{
{fields_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:
@@ -590,11 +624,16 @@ class APIViewTestCases:
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())
@@ -602,7 +641,7 @@ class APIViewTestCases:
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):
@@ -650,54 +689,71 @@ class APIViewTestCases:
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_list_objects(self):
url = reverse('graphql')
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)
# 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)
# 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))
# 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)
# 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]), 0)
# 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))
# Remove permission constraint
obj_perm.constraints = None
obj_perm.save()
# 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)
# 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]), self.model.objects.count())
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())
# 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')
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(
@@ -708,11 +764,26 @@ class APIViewTestCases:
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]), 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,

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class VirtualizationQuery:
class VirtualizationQueryV1:
cluster: ClusterType = strawberry_django.field()
cluster_list: List[ClusterType] = strawberry_django.field()
@@ -25,3 +26,24 @@ class VirtualizationQuery:
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()
cluster_list: OffsetPaginated[ClusterType] = strawberry_django.offset_paginated()
cluster_group: ClusterGroupType = strawberry_django.field()
cluster_group_list: OffsetPaginated[ClusterGroupType] = strawberry_django.offset_paginated()
cluster_type: ClusterTypeType = strawberry_django.field()
cluster_type_list: OffsetPaginated[ClusterTypeType] = strawberry_django.offset_paginated()
virtual_machine: VirtualMachineType = strawberry_django.field()
virtual_machine_list: OffsetPaginated[VirtualMachineType] = strawberry_django.offset_paginated()
vm_interface: VMInterfaceType = strawberry_django.field()
vm_interface_list: OffsetPaginated[VMInterfaceType] = strawberry_django.offset_paginated()
virtual_disk: VirtualDiskType = strawberry_django.field()
virtual_disk_list: OffsetPaginated[VirtualDiskType] = strawberry_django.offset_paginated()

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class VPNQuery:
class VPNQueryV1:
ike_policy: IKEPolicyType = strawberry_django.field()
ike_policy_list: List[IKEPolicyType] = strawberry_django.field()
@@ -37,3 +38,36 @@ class VPNQuery:
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()
ike_policy_list: OffsetPaginated[IKEPolicyType] = strawberry_django.offset_paginated()
ike_proposal: IKEProposalType = strawberry_django.field()
ike_proposal_list: OffsetPaginated[IKEProposalType] = strawberry_django.offset_paginated()
ipsec_policy: IPSecPolicyType = strawberry_django.field()
ipsec_policy_list: OffsetPaginated[IPSecPolicyType] = strawberry_django.offset_paginated()
ipsec_profile: IPSecProfileType = strawberry_django.field()
ipsec_profile_list: OffsetPaginated[IPSecProfileType] = strawberry_django.offset_paginated()
ipsec_proposal: IPSecProposalType = strawberry_django.field()
ipsec_proposal_list: OffsetPaginated[IPSecProposalType] = strawberry_django.offset_paginated()
l2vpn: L2VPNType = strawberry_django.field()
l2vpn_list: OffsetPaginated[L2VPNType] = strawberry_django.offset_paginated()
l2vpn_termination: L2VPNTerminationType = strawberry_django.field()
l2vpn_termination_list: OffsetPaginated[L2VPNTerminationType] = strawberry_django.offset_paginated()
tunnel: TunnelType = strawberry_django.field()
tunnel_list: OffsetPaginated[TunnelType] = strawberry_django.offset_paginated()
tunnel_group: TunnelGroupType = strawberry_django.field()
tunnel_group_list: OffsetPaginated[TunnelGroupType] = strawberry_django.offset_paginated()
tunnel_termination: TunnelTerminationType = strawberry_django.field()
tunnel_termination_list: OffsetPaginated[TunnelTerminationType] = strawberry_django.offset_paginated()

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class WirelessQuery:
class WirelessQueryV1:
wireless_lan: WirelessLANType = strawberry_django.field()
wireless_lan_list: List[WirelessLANType] = strawberry_django.field()
@@ -16,3 +17,15 @@ class WirelessQuery:
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()
wireless_lan_list: OffsetPaginated[WirelessLANType] = strawberry_django.offset_paginated()
wireless_lan_group: WirelessLANGroupType = strawberry_django.field()
wireless_lan_group_list: OffsetPaginated[WirelessLANGroupType] = strawberry_django.offset_paginated()
wireless_link: WirelessLinkType = strawberry_django.field()
wireless_link_list: OffsetPaginated[WirelessLinkType] = strawberry_django.offset_paginated()