Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/infrahub/graphql/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from infrahub.graphql.mutations.attribute import BaseAttributeCreate, BaseAttributeUpdate
from infrahub.graphql.mutations.graphql_query import InfrahubGraphQLQueryMutation
from infrahub.graphql.mutations.profile import InfrahubProfileMutation
from infrahub.graphql.types.scalars import NonNegativeInt
from infrahub.types import ATTRIBUTE_TYPES, InfrahubDataType, get_attribute_type

from .directives import DIRECTIVES
Expand Down Expand Up @@ -912,7 +913,7 @@ def generate_filters(
dict: A Dictionary containing all the filters with their name as the key and their Type as value
"""

filters: dict[str, Any] = {"offset": graphene.Int(), "limit": graphene.Int(), "order": OrderInput()}
filters: dict[str, Any] = {"offset": NonNegativeInt(), "limit": NonNegativeInt(), "order": OrderInput()}
default_filters: list[str] = list(filters.keys())

filters["ids"] = graphene.List(graphene.ID)
Expand Down
5 changes: 3 additions & 2 deletions backend/infrahub/graphql/queries/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from infrahub.core.protocols import InternalAccountToken
from infrahub.exceptions import PermissionDeniedError
from infrahub.graphql.field_extractor import extract_graphql_fields
from infrahub.graphql.types.scalars import NonNegativeInt

if TYPE_CHECKING:
from graphql import GraphQLResolveInfo
Expand Down Expand Up @@ -63,8 +64,8 @@ async def resolve_account_tokens(

AccountToken = Field(
AccountTokenEdges,
limit=Int(required=False),
offset=Int(required=False),
limit=NonNegativeInt(required=False),
offset=NonNegativeInt(required=False),
resolver=resolve_account_tokens,
required=True,
)
Expand Down
7 changes: 4 additions & 3 deletions backend/infrahub/graphql/queries/branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

from typing import TYPE_CHECKING, Any

from graphene import ID, Field, Int, List, NonNull, String
from graphene import ID, Field, List, NonNull, String

from infrahub.exceptions import ValidationError
from infrahub.graphql.field_extractor import extract_graphql_fields
from infrahub.graphql.types import BranchType, InfrahubBranch, InfrahubBranchType
from infrahub.graphql.types.scalars import NonNegativeInt

if TYPE_CHECKING:
from graphql import GraphQLResolveInfo
Expand Down Expand Up @@ -56,8 +57,8 @@ async def infrahub_branch_resolver(

InfrahubBranchQueryList = Field(
InfrahubBranchType,
offset=Int(),
limit=Int(),
offset=NonNegativeInt(),
limit=NonNegativeInt(),
description="Retrieve paginated information about active branches.",
resolver=infrahub_branch_resolver,
required=True,
Expand Down
5 changes: 3 additions & 2 deletions backend/infrahub/graphql/queries/diff/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from infrahub.dependencies.registry import get_component_registry
from infrahub.graphql.enums import ConflictSelection as GraphQLConflictSelection
from infrahub.graphql.field_extractor import extract_graphql_fields
from infrahub.graphql.types.scalars import NonNegativeInt

if TYPE_CHECKING:
from datetime import datetime
Expand Down Expand Up @@ -586,8 +587,8 @@ class DiffTreeQueryFilters(InputObjectType):
root_node_uuids=Argument(List(String), deprecation_reason="replaced by filters"),
include_parents=Boolean(),
filters=DiffTreeQueryFilters(),
limit=Int(),
offset=Int(),
limit=NonNegativeInt(),
offset=NonNegativeInt(),
resolver=DiffTreeResolver().resolve,
required=False,
)
Expand Down
5 changes: 3 additions & 2 deletions backend/infrahub/graphql/queries/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from infrahub.exceptions import ValidationError
from infrahub.graphql.field_extractor import extract_graphql_fields
from infrahub.graphql.types.event import EventNodes, EventTypeFilter
from infrahub.graphql.types.scalars import NonNegativeInt
from infrahub.task_manager.event import PrefectEvent
from infrahub.task_manager.models import InfrahubEventFilter

Expand Down Expand Up @@ -95,8 +96,8 @@ async def query(

Event = Field(
Events,
limit=Int(required=False),
offset=Int(required=False),
limit=NonNegativeInt(required=False),
offset=NonNegativeInt(required=False),
level=Int(required=False),
has_children=Boolean(required=False, description="Filter events based on if they can have children or not"),
event_type=List(NonNull(String), description="Filter events that match a specific type"),
Expand Down
5 changes: 3 additions & 2 deletions backend/infrahub/graphql/queries/relationship.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from infrahub.core.query.relationship import RelationshipGetByIdentifierQuery
from infrahub.graphql.field_extractor import extract_graphql_fields
from infrahub.graphql.types import RelationshipNode
from infrahub.graphql.types.scalars import NonNegativeInt

if TYPE_CHECKING:
from graphql import GraphQLResolveInfo
Expand Down Expand Up @@ -76,8 +77,8 @@ async def resolve(
Relationships,
ids=List(NonNull(String), required=True),
excluded_namespaces=List(String),
limit=Int(required=False),
offset=Int(required=False),
limit=NonNegativeInt(required=False),
offset=NonNegativeInt(required=False),
resolver=Relationships.resolve,
required=True,
)
7 changes: 4 additions & 3 deletions backend/infrahub/graphql/queries/resource_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typing import TYPE_CHECKING, Any

from graphene import BigInt, Field, Float, Int, List, NonNull, ObjectType, String
from graphene import BigInt, Field, Float, List, NonNull, ObjectType, String

from infrahub.core import registry
from infrahub.core.constants import InfrahubKind
Expand All @@ -16,6 +16,7 @@
)
from infrahub.exceptions import NodeNotFoundError, SchemaNotFoundError, ValidationError
from infrahub.graphql.field_extractor import extract_graphql_fields
from infrahub.graphql.types.scalars import NonNegativeInt
from infrahub.pools.number import NumberUtilizationGetter

if TYPE_CHECKING:
Expand Down Expand Up @@ -340,8 +341,8 @@ async def resolve_number_pool_utilization(
PoolAllocated,
pool_id=String(required=True),
resource_id=String(required=True),
limit=Int(required=False),
offset=Int(required=False),
limit=NonNegativeInt(required=False),
offset=NonNegativeInt(required=False),
resolver=PoolAllocated.resolve,
required=True,
)
Expand Down
3 changes: 2 additions & 1 deletion backend/infrahub/graphql/queries/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from infrahub.core.constants import InfrahubKind
from infrahub.core.manager import NodeManager
from infrahub.graphql.field_extractor import extract_graphql_fields
from infrahub.graphql.types.scalars import NonNegativeInt

if TYPE_CHECKING:
from graphql import GraphQLResolveInfo
Expand Down Expand Up @@ -146,7 +147,7 @@ async def search_resolver(
InfrahubSearchAnywhere = Field(
NodeEdges,
q=String(required=True),
limit=Int(required=False),
limit=NonNegativeInt(required=False),
partial_match=Boolean(required=False),
resolver=search_resolver,
required=True,
Expand Down
9 changes: 5 additions & 4 deletions backend/infrahub/graphql/queries/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from prefect.client.schemas.objects import StateType

from infrahub.graphql.field_extractor import extract_graphql_fields
from infrahub.graphql.types.scalars import NonNegativeInt
from infrahub.graphql.types.task import TaskNodes, TaskState
from infrahub.task_manager.task import PrefectTask
from infrahub.workflows.constants import WorkflowTag
Expand Down Expand Up @@ -105,16 +106,16 @@ async def query(

Task = Field(
Tasks,
limit=Int(required=False),
offset=Int(required=False),
limit=NonNegativeInt(required=False),
offset=NonNegativeInt(required=False),
related_node__ids=List(String),
branch=String(required=False),
state=List(TaskState),
workflow=List(String),
ids=List(String),
q=String(required=False),
log_limit=Int(required=False),
log_offset=Int(required=False),
log_limit=NonNegativeInt(required=False),
log_offset=NonNegativeInt(required=False),
resolver=Tasks.resolve,
required=True,
)
Expand Down
92 changes: 92 additions & 0 deletions backend/infrahub/graphql/types/scalars.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from typing import Any

from graphene import Scalar
from graphql import language

from infrahub.exceptions import ValidationError


class NonNegativeInt(Scalar):
"""A GraphQL scalar type that validates non-negative integer values.

This scalar ensures that values are integers >= 0. It accepts None (null in GraphQL)
and rejects negative integers by raising ValidationError.
"""

@staticmethod
def serialize(value: int | None) -> int | None:
"""Serialize the value for output.

Args:
value: The value to serialize.

Returns:
The validated non-negative integer or None.

Raises:
ValidationError: If the value is negative.
"""

return NonNegativeInt._validate(value)

@staticmethod
def parse_value(value: Any) -> int | None:
"""Parse a value from variables.

Args:
value: The input value from GraphQL variables.

Returns:
The validated non-negative integer or None.

Raises:
ValidationError: If the value is negative or cannot be converted to int.
"""

return NonNegativeInt._validate(value)

@staticmethod
def parse_literal(node: language.ast.ValueNode) -> int | None:
"""Parse a value from an AST literal node.

Args:
node: The AST node representing the literal value.

Returns:
The validated non-negative integer or None.

Raises:
ValidationError: If the node is not an IntValueNode or the value is negative.
"""

if isinstance(node, language.ast.IntValueNode):
return NonNegativeInt._validate(int(node.value))

raise ValidationError("Value must be a non-negative integer")

@staticmethod
def _validate(value: Any) -> int | None:
"""Validate that the value is a non-negative integer.

Args:
value: The value to validate.

Returns:
The validated non-negative integer or None if the input is None.

Raises:
ValidationError: If the value is negative or cannot be converted to int.
"""

if value is None:
return None

try:
value = int(value)
except (ValueError, TypeError) as exc:
raise ValidationError("Value must be a non-negative integer") from exc

if value < 0:
raise ValidationError("Value must be a non-negative integer")

return value
4 changes: 2 additions & 2 deletions backend/tests/unit/graphql/queries/test_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ async def test_paginated_branch_query__returns_error_on_invalid_offset_or_limit(
variable_values={},
)
assert len(all_branches.errors)
assert all_branches.errors[0].message == "offset must be >= 0"
assert "non-negative integer" in all_branches.errors[0].message.lower()

query = """
query {
Expand All @@ -300,4 +300,4 @@ async def test_paginated_branch_query__returns_error_on_invalid_offset_or_limit(
variable_values={},
)
assert len(all_branches.errors)
assert all_branches.errors[0].message == "limit must be >= 1"
assert "non-negative integer" in all_branches.errors[0].message.lower()
4 changes: 2 additions & 2 deletions backend/tests/unit/graphql/queries/test_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
$branch: [String!],
$account: [String!],
$parent__ids: [String!],
$limit: Int,
$offset: Int
$limit: NonNegativeInt,
$offset: NonNegativeInt
$level: Int
$has_children: Boolean
$event_type: [String!]
Expand Down
6 changes: 3 additions & 3 deletions backend/tests/unit/graphql/queries/test_ipam.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ async def test_ip_address_include_available_filtered_by_kind(
default_branch.update_schema_hash()
gql_params = await prepare_graphql_params(db=db, branch=default_branch)
query = """
query($prefix: ID!, $limit: Int!, $kinds: [String!]) {
query($prefix: ID!, $limit: NonNegativeInt!, $kinds: [String!]) {
BuiltinIPAddress(ip_prefix__ids: [$prefix], include_available: true, kinds: $kinds, limit: $limit) {
edges {
node {
Expand Down Expand Up @@ -742,7 +742,7 @@ async def test_ipaddress_include_available_pagination(
gql_params = await prepare_graphql_params(db=db, branch=default_branch)

query = """
query($prefix: ID!, $limit: Int!, $offset: Int!) {
query($prefix: ID!, $limit: NonNegativeInt!, $offset: NonNegativeInt!) {
BuiltinIPAddress(ip_prefix__ids: [$prefix], include_available: true, limit: $limit, offset: $offset) {
edges {
node {
Expand Down Expand Up @@ -1051,7 +1051,7 @@ async def test_ipprefix_include_available_pagination(
gql_params = await prepare_graphql_params(db=db, branch=default_branch)

query = """
query($prefix: ID!, $limit: Int!, $offset: Int!) {
query($prefix: ID!, $limit: NonNegativeInt!, $offset: NonNegativeInt!) {
BuiltinIPPrefix(parent__ids: [$prefix], include_available: true, limit: $limit, offset: $offset) {
edges {
node {
Expand Down
Loading
Loading