Repository: netbox-community/netbox

Issue:
Show IP address version in GraphQL queries
### NetBox version

v3.4.5

### Feature type

Data model extension

### Proposed functionality

I would like GraphQL queries to able to show the address family of an IP Address as the REST API currently does.

REST via https://netbox.acronis.work/api/ipam/ip-addresses/ endpoint:
```
{
  "family": {
    "value": 4,
    "label": "IPv4"
  }
}
```
GraphQL has no concept of the "family" field.

Per Jeremy on Slack:
Currently "family" is an attribute of the IPAddress model rather than an actual database field. The REST API serializer has an explicit field defined to convey this value whereas the GraphQL API serializer does not.

### Use case

When pulling data from Netbox to configure network devices, I need to be able to distinguish between address families. I could theoretically do this in the Jinja template but I prefer to minimise data manipulation at that level. 

### Database changes

I believe this would require a new field.

### External dependencies

_No response_


Discussion:
Did some digging and I believe we can expose the family information without having to make DB schema changes.

We can enhance the model at the graphene level and use a custom resolver.

Here's an example (will open a PR shortly):

```
(netbox) rodrigo@Rodrigos-MacBook-Pro netbox % git diff
diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py
index b8f6221bc..7732b78d3 100644
--- a/netbox/ipam/graphql/types.py
+++ b/netbox/ipam/graphql/types.py
@@ -64,14 +64,29 @@ class FHRPGroupAssignmentType(BaseObjectType):
         filterset_class = filtersets.FHRPGroupAssignmentFilterSet
 
 
+class IPAddressFamilyType(graphene.ObjectType):
+
+    value = graphene.Int()
+    label = graphene.String()
+
+    def __init__(self, value):
+        self.value = value
+        self.label = 'IPv4' if value == 4 else 'IPv6'
+
 class IPAddressType(NetBoxObjectType):
     assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType')
+    family = graphene.Field(IPAddressFamilyType)
 
     class Meta:
         model = models.IPAddress
         exclude = ('assigned_object_type', 'assigned_object_id')
         filterset_class = filtersets.IPAddressFilterSet
 
+    def resolve_family(self, _):
+        # Note that self, is an instance of models.IPAddress
+        # thus resolves to the address family value.
+        return IPAddressFamilyType(self.family)
+
     def resolve_role(self, info):
         return self.role or None
 
```

I validated the expected behavior with:

```
#!/bin/bash

curl \
  --silent \
  -H "Authorization: Token $(./print-token.sh)" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  "http://localhost:${NETBOX_PORT}/graphql/" \
  --data '{"query": "query{ ip_address_list{ address, family { value, label } } }"}'
```

Which returns:

```
rodrigo@Rodrigos-MacBook-Pro scripts % ./query-ips-graphql.sh | jq .data
{
  "ip_address_list": [
    {
      "address": "192.168.1.1/32",
      "family": {
        "value": 4,
        "label": "IPv4"
      }
    },
    {
      "address": "2001:db8:3333:4444:5555:6666:7777:8888/32",
      "family": {
        "value": 6,
        "label": "IPv6"
      }
    }
  ]
}
```

Resolving patch:
diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py
--- a/netbox/ipam/graphql/types.py
+++ b/netbox/ipam/graphql/types.py
@@ -27,6 +27,28 @@
 )
 
 
+class IPAddressFamilyType(graphene.ObjectType):
+
+    value = graphene.Int()
+    label = graphene.String()
+
+    def __init__(self, value):
+        self.value = value
+        self.label = f'IPv{value}'
+
+
+class BaseIPAddressFamilyType:
+    '''
+    Base type for models that need to expose their IPAddress family type.
+    '''
+    family = graphene.Field(IPAddressFamilyType)
+
+    def resolve_family(self, _):
+        # Note that self, is an instance of models.IPAddress
+        # thus resolves to the address family value.
+        return IPAddressFamilyType(self.family)
+
+
 class ASNType(NetBoxObjectType):
     asn = graphene.Field(BigInt)
 
@@ -36,7 +58,7 @@ class Meta:
         filterset_class = filtersets.ASNFilterSet
 
 
-class AggregateType(NetBoxObjectType):
+class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
 
     class Meta:
         model = models.Aggregate
@@ -64,7 +86,7 @@ class Meta:
         filterset_class = filtersets.FHRPGroupAssignmentFilterSet
 
 
-class IPAddressType(NetBoxObjectType):
+class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
     assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType')
 
     class Meta:
@@ -87,7 +109,7 @@ def resolve_role(self, info):
         return self.role or None
 
 
-class PrefixType(NetBoxObjectType):
+class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
 
     class Meta:
         model = models.Prefix


Test patch:
diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py
--- a/netbox/utilities/testing/api.py
+++ b/netbox/utilities/testing/api.py
@@ -17,6 +17,8 @@
 from .base import ModelTestCase
 from .utils import disable_warnings
 
+from ipam.graphql.types import IPAddressFamilyType
+
 
 __all__ = (
     'APITestCase',
@@ -460,6 +462,8 @@ def _build_query(self, name, **filters):
                     # TODO: Come up with something more elegant
                     # Temporary hack to support automated testing of reverse generic relations
                     fields_string += f'{field_name} {{ id }}\n'
+                elif inspect.isclass(field.type) and issubclass(field.type, IPAddressFamilyType):
+                    fields_string += f'{field_name} {{ value, label }}\n'
                 else:
                     fields_string += f'{field_name}\n'