netbox icon indicating copy to clipboard operation
netbox copied to clipboard

9856 Replace graphene with Strawberry

Open arthanson opened this issue 1 year ago • 3 comments

Fixes: #9856

Replaces django-graphene with strawberry-django a new engine for handling GraphQL queries. Notable changes:

  • Converts all GraphQL types to Strawberry Types
  • Includes much more updated GraphiQL browser
  • Decorator to auto-generate Strawberry required data-classes for filters as Strawberry doesn't support django-filter currently.

Example Queries:

{
  circuit_termination_list(filters: {cabled: false}) {
    id
    display
    cable {
      id
    }
  }
}

and

{
  location_list {
    image_attachments {
      id
    }
    contacts {
      id
    }
    changelog {
      id
    }
    display
    class_type
    tags {
      id
    }
    custom_fields
    id
    created
    last_updated
    custom_field_data
    name
    slug
    description
    site {
      id
    }
    status
    tenant {
      id
    }
    lft
    rght
    tree_id
    level
    children {
      id
    }
    powerpanel_set {
      id
    }
    cabletermination_set {
      id
    }
    racks {
      id
    }
    vlan_groups {
      id
    }
    parent {
      id
    }
    devices {
      id
    }
  }
}

Filtering lookups are different between the two libraries:

Graphene

user_list(is_superuser: true) {
    id
}

Strawberry

user_list(filters: {is_superuser: true}) {
   id
}

The filtering lookup syntax (greater than, less than, case-insensitive compare) is also different: Graphene

{
  user_list(username__ic: "bob") {
    username
  }
}

Strawberry

{
  user_list(filters: {username: {i_contains: "bob"}}) {
    username
  }
}

For stawberry, allowing lookups on fields forces the lookup to be present, so it forces this somewhat awkward syntax: Graphene

{
  user_list(id: "3") {
    id
  }
}

Strawberry

{
  user_list(filters: {id: {exact: 3}}) {
    id
  }
}

Performance optimizer

Below is a ridiculous nested query that can be used to show optimization

old graphene: 55 queries strawberry: 24 queries

{
  location_list {
    image_attachments {
      id
    }
    contacts {
      id
    }
    changelog {
      id
    }
    display
    class_type
    tags {
      id
    }
    custom_fields
    id
    created
    last_updated
    custom_field_data
    name
    slug
    description
    site {
      id
      name
      locations {
        id
        name
      }
    }
    status
    tenant {
      id
      sites {
        id
        name
      }
    }
    lft
    rght
    tree_id
    level
    children {
      id
    }
    powerpanel_set {
      id
    }
    cabletermination_set {
      id
    }
    racks {
      id
      site {
        id
        name
      }
    }
    vlan_groups {
      id
    }
    parent {
      id
    }
    devices {
      id
    }
  }
}

arthanson avatar Feb 13 '24 16:02 arthanson

Remaining work:

  • [x] fix type definitions
  • [x] fix type data types
  • [x] fix filtersets
  • [x] Auto generate Filtersets definitions
  • [x] Choice field handling
  • [x] Fix GraphQL tests auto-discovery of fields
  • [x] Update netbox/graphql/views.py GraphQLView (GraphiQL)
  • [x] netbox/netbox/graphql/fields.py (ObjectField, ObjectListfield)? - I think this is obsolete from optimizer
  • [x] netbox/netbox/graphql/scalars.py (BigInt)?
  • [x] netbox/netbox/graphql/utils.py (get_graphene_type)?
  • [x] netbox/dcim/graphql/gfk_mixins.py (gfk mixins)?

arthanson avatar Feb 13 '24 17:02 arthanson

Including some GraphQL queries here I used for testing as they are handy for reference - just got some GFK stuff working! :tada: :

  {
    circuit_termination_list {
      id
      link_peers {
        __typename
        ... on InterfaceType {
          id
          name
        }
        ... on RearPortType {
          id
          display
          device {
            pk
          }
        }
      }
    }
  }

arthanson avatar Feb 15 '24 22:02 arthanson

Script for testing match of the schemas (will keep it updated as needed):

# An example to get the remaining rate limit using the Github GraphQL API.

import requests

headers = {"Authorization": "Bearer YOUR API KEY"}
headers = None
new_url = 'http://127.0.0.1:8000/graphql/'
old_url = 'https://demo.netbox.dev/graphql/'


def run_query(url, query):  # A simple function to use requests.post to make the API call. Note the json= section.
    request = requests.post(url, json={'query': query}, headers=headers)
    if request.status_code == 200:
        return request.json()
    else:
        raise Exception("Query failed to run by returning code of {}. {}".format(request.status_code, query))


# The GraphQL query (with a few aditional bits included) itself defined as a multi-line string.
query_schema = """
{
  __schema {
    queryType {
      fields {
        name
        type {
          name
        }
      }
    }
  }
}
"""


def get_all_endpoints(url):
    query = """
  {
    __schema {
      types {
        name
      }
    }
  }
  """

    types = run_query(url, query)
    types = types["data"]["__schema"]["types"]

    all_types = []
    for type in types:
        name = type["name"]
        if name.endswith("Type"):
            all_types.append(name)

    all_types.sort()
    return all_types


new_endpoints = get_all_endpoints(new_url)
old_endpoints = get_all_endpoints(old_url)

print("Main Endpoint Diff")
print(set(old_endpoints) - set(new_endpoints))
print("")

def get_endpoint_types(url, endpoint):
    query = f"""
  {{
    __type(name: "{endpoint}") {{
      name
      fields {{
        name
        type {{
          name
          kind
        }}
      }}
    }}
  }}
  """
    types = run_query(url, query)
    types = types["data"]["__type"]["fields"]

    all_types = []
    for type in types:
        name = type["name"]
        all_types.append(name)

    all_types.sort()
    return all_types

checked = 0
failed = 0
success = 0
for endpoint in new_endpoints:
    if endpoint != 'DjangoImageType' and endpoint != 'DjangoModelType':
        checked += 1
        new_types = get_endpoint_types(new_url, endpoint)
        old_types = get_endpoint_types(old_url, endpoint)
        diff = set(old_types) - set(new_types)
        if diff:
            failed += 1
            print(endpoint)
            print("--------------------")
            print(diff)
            print("")
        else:
            success += 1

print(f"checked: {checked}")
print(f"failed: {failed}")
print(f"success: {success}")

arthanson avatar Feb 15 '24 22:02 arthanson

Fixed up the GraphiQL view, the old method of bundling the assets wouldn't work as the new GraphiQL has some include packages that ESBuild can't translate to ES6, see: (https://github.com/graphql/graphiql/discussions/2525) - we could switch to webpack which does handle it, but as it is just for GraphiQL it seemed fine to just statically link the separate files. Changed the ESBuild script to copy the files out of the node-packages.

django-graphical-debug-toolbar is no longer needed, so removed that. The strawberry one is handled by middleware already.

arthanson avatar Mar 20 '24 21:03 arthanson