Skip to content
This repository was archived by the owner on Jun 13, 2025. It is now read-only.

Commit 2115d29

Browse files
Merge remote-tracking branch 'origin/main' into sshin/uv
2 parents d6067c6 + a2a4844 commit 2115d29

File tree

11 files changed

+808
-17
lines changed

11 files changed

+808
-17
lines changed

graphql_api/tests/test_commit.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3256,3 +3256,70 @@ def test_bundle_analysis_report_size_filtered_no_value(self, get_storage_service
32563256
"bundleDataFiltered": {"size": {"gzip": 20, "uncompress": 20}},
32573257
},
32583258
}
3259+
3260+
@patch("graphql_api.dataloader.bundle_analysis.get_appropriate_storage_service")
3261+
def test_bundle_analysis_asset_routes(self, get_storage_service):
3262+
storage = MemoryStorageService({})
3263+
get_storage_service.return_value = storage
3264+
3265+
head_commit_report = CommitReportFactory(
3266+
commit=self.commit, report_type=CommitReport.ReportType.BUNDLE_ANALYSIS
3267+
)
3268+
3269+
with open(
3270+
"./services/tests/samples/bundle_with_assets_and_modules.sqlite", "rb"
3271+
) as f:
3272+
storage_path = StoragePaths.bundle_report.path(
3273+
repo_key=ArchiveService.get_archive_hash(self.repo),
3274+
report_key=head_commit_report.external_id,
3275+
)
3276+
storage.write_file(get_bucket_name(), storage_path, f)
3277+
3278+
query = """
3279+
query FetchCommit($org: String!, $repo: String!, $commit: String!) {
3280+
owner(username: $org) {
3281+
repository(name: $repo) {
3282+
... on Repository {
3283+
commit(id: $commit) {
3284+
bundleAnalysis {
3285+
bundleAnalysisReport {
3286+
__typename
3287+
... on BundleAnalysisReport {
3288+
bundle(name: "b5") {
3289+
asset(name: "assets/LazyComponent-fcbb0922.js") {
3290+
name
3291+
normalizedName
3292+
routes
3293+
}
3294+
}
3295+
}
3296+
}
3297+
}
3298+
}
3299+
}
3300+
}
3301+
}
3302+
}
3303+
"""
3304+
3305+
variables = {
3306+
"org": self.org.username,
3307+
"repo": self.repo.name,
3308+
"commit": self.commit.commitid,
3309+
}
3310+
data = self.gql_request(query, variables=variables)
3311+
commit = data["owner"]["repository"]["commit"]
3312+
3313+
asset_report = commit["bundleAnalysis"]["bundleAnalysisReport"]["bundle"][
3314+
"asset"
3315+
]
3316+
3317+
assert asset_report is not None
3318+
assert asset_report["name"] == "assets/LazyComponent-fcbb0922.js"
3319+
assert asset_report["normalizedName"] == "assets/LazyComponent-*.js"
3320+
assert asset_report["routes"] == [
3321+
"/",
3322+
"/about",
3323+
"/login",
3324+
"/super/long/url/path",
3325+
]

graphql_api/tests/test_views.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import json
22
from unittest.mock import Mock, patch
33

4-
from ariadne import ObjectType, make_executable_schema
4+
from ariadne import ObjectType, gql, make_executable_schema
55
from ariadne.validation import cost_directive
66
from django.test import RequestFactory, TestCase, override_settings
77
from django.urls import ResolverMatch
@@ -39,11 +39,34 @@ def generate_cost_test_schema():
3939
return make_executable_schema([types, cost_directive], query_bindable)
4040

4141

42+
def generate_schema_with_required_variables():
43+
types = gql(
44+
"""
45+
type Query {
46+
person_exists(name: String!): Boolean
47+
stuff: String
48+
}
49+
"""
50+
)
51+
query_bindable = ObjectType("Query")
52+
53+
# Add a resolver for the `person_exists` field
54+
@query_bindable.field("person_exists")
55+
def resolve_person_exists(_, info, name):
56+
return name is not None # Example resolver logic
57+
58+
return make_executable_schema(types, query_bindable)
59+
60+
4261
class AriadneViewTestCase(GraphQLTestHelper, TestCase):
43-
async def do_query(self, schema, query="{ failing }"):
62+
async def do_query(self, schema, query="{ failing }", variables=None):
4463
view = AsyncGraphqlView.as_view(schema=schema)
64+
data = {"query": query}
65+
if variables is not None:
66+
data["variables"] = variables
67+
4568
request = RequestFactory().post(
46-
"/graphql/gh", {"query": query}, content_type="application/json"
69+
"/graphql/gh", data, content_type="application/json"
4770
)
4871
match = ResolverMatch(func=lambda: None, args=(), kwargs={"service": "github"})
4972

@@ -256,3 +279,33 @@ def test_client_ip_from_remote_addr(self):
256279

257280
result = view.get_client_ip(request)
258281
assert result == "lol"
282+
283+
async def test_required_variable_present(self):
284+
schema = generate_schema_with_required_variables()
285+
286+
query = """
287+
query ($name: String!) {
288+
person_exists(name: $name)
289+
}
290+
"""
291+
292+
# Provide the variable
293+
data = await self.do_query(schema, query=query, variables={"name": "Bob"})
294+
295+
assert data is not None
296+
assert "data" in data
297+
assert data["data"]["person_exists"] is True
298+
299+
async def test_required_variable_missing(self):
300+
schema = generate_schema_with_required_variables()
301+
302+
query = """
303+
query ($name: String!) {
304+
person_exists(name: $name)
305+
}
306+
"""
307+
308+
# Don't provide the variable
309+
data = await self.do_query(schema, query=query, variables={})
310+
311+
assert data == {"detail": "Missing required variables: name", "status": 400}

graphql_api/types/bundle_analysis/base.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ type BundleAsset {
5555
after: DateTime
5656
branch: String
5757
): BundleAnalysisMeasurements
58+
routes: [String!]
5859
}
5960

6061
type BundleReport {

graphql_api/types/bundle_analysis/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,13 @@ def resolve_asset_report_measurements(
158158
return bundle_analysis_measurements.compute_asset(bundle_asset)
159159

160160

161+
@bundle_asset_bindable.field("routes")
162+
def resolve_routes(
163+
bundle_asset: AssetReport, info: GraphQLResolveInfo
164+
) -> Optional[List[str]]:
165+
return ["/", "/about", "/login", "/super/long/url/path"]
166+
167+
161168
# ============= Bundle Report Bindable =============
162169

163170

graphql_api/validation.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,59 @@
1-
from typing import Any, Type
1+
from typing import Any, Dict, Type
22

33
from graphql import GraphQLError, ValidationRule
4-
from graphql.language.ast import DocumentNode, FieldNode, OperationDefinitionNode
4+
from graphql.language.ast import (
5+
DocumentNode,
6+
FieldNode,
7+
OperationDefinitionNode,
8+
VariableDefinitionNode,
9+
)
510
from graphql.validation import ValidationContext
611

712

13+
class MissingVariablesError(Exception):
14+
"""
15+
Custom error class to represent errors where required variables defined in the query does
16+
not have a matching definition in the variables part of the request. Normally when this
17+
scenario occurs it would raise a GraphQLError type but that would cause a uncaught
18+
exception for some reason. The aim of this is to surface the error in the response clearly
19+
and to prevent internal server errors when it occurs.
20+
"""
21+
22+
pass
23+
24+
25+
def create_required_variables_rule(variables: Dict) -> Type[ValidationRule]:
26+
class RequiredVariablesValidationRule(ValidationRule):
27+
def __init__(self, context: ValidationContext) -> None:
28+
super().__init__(context)
29+
self.variables = variables
30+
31+
def enter_operation_definition(
32+
self, node: OperationDefinitionNode, *_args: Any
33+
) -> None:
34+
# Get variable definitions
35+
variable_definitions = node.variable_definitions or []
36+
37+
# Extract variables marked as Non Null
38+
required_variables = [
39+
var_def.variable.name.value
40+
for var_def in variable_definitions
41+
if isinstance(var_def, VariableDefinitionNode)
42+
and var_def.type.kind == "non_null_type"
43+
]
44+
45+
# Check if these required variables are provided
46+
missing_variables = [
47+
var for var in required_variables if var not in self.variables
48+
]
49+
if missing_variables:
50+
raise MissingVariablesError(
51+
f"Missing required variables: {', '.join(missing_variables)}",
52+
)
53+
54+
return RequiredVariablesValidationRule
55+
56+
857
def create_max_depth_rule(max_depth: int) -> Type[ValidationRule]:
958
class MaxDepthRule(ValidationRule):
1059
def __init__(self, context: ValidationContext) -> None:

graphql_api/views.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@
2828
from services.redis_configuration import get_redis_connection
2929

3030
from .schema import schema
31-
from .validation import create_max_aliases_rule, create_max_depth_rule
31+
from .validation import (
32+
MissingVariablesError,
33+
create_max_aliases_rule,
34+
create_max_depth_rule,
35+
create_required_variables_rule,
36+
)
3237

3338
log = logging.getLogger(__name__)
3439

@@ -198,6 +203,7 @@ def get_validation_rules(
198203
data: dict,
199204
) -> Optional[Collection]:
200205
return [
206+
create_required_variables_rule(variables=data.get("variables")),
201207
create_max_aliases_rule(max_aliases=settings.GRAPHQL_MAX_ALIASES),
202208
create_max_depth_rule(max_depth=settings.GRAPHQL_MAX_DEPTH),
203209
cost_validator(
@@ -259,7 +265,16 @@ async def post(self, request, *args, **kwargs):
259265
)
260266

261267
with RequestFinalizer(request):
262-
response = await super().post(request, *args, **kwargs)
268+
try:
269+
response = await super().post(request, *args, **kwargs)
270+
except MissingVariablesError as e:
271+
return JsonResponse(
272+
data={
273+
"status": 400,
274+
"detail": str(e),
275+
},
276+
status=400,
277+
)
263278

264279
content = response.content.decode("utf-8")
265280
data = json.loads(content)

gunicorn.conf.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,48 @@
1+
import logging
12
import os
23

4+
from gunicorn.glogging import Logger
35
from prometheus_client import multiprocess
46

57

68
def child_exit(server, worker):
79
if worker and worker.pid and "PROMETHEUS_MULTIPROC_DIR" in os.environ:
810
multiprocess.mark_process_dead(worker.pid)
11+
12+
13+
class CustomGunicornLogger(Logger):
14+
"""
15+
A custom class for logging gunicorn startup logs, these are for the logging that takes
16+
place before the Django app starts and takes over with its own defined logging formats.
17+
This class ensures the gunicorn minimum log level to be INFO instead of the default ERROR.
18+
"""
19+
20+
def setup(self, cfg):
21+
super().setup(cfg)
22+
custom_format = "[%(levelname)s] [%(process)d] [%(asctime)s] %(message)s "
23+
date_format = "%Y-%m-%d %H:%M:%S %z"
24+
formatter = logging.Formatter(fmt=custom_format, datefmt=date_format)
25+
26+
# Update handlers with the custom formatter
27+
for handler in self.error_log.handlers:
28+
handler.setFormatter(formatter)
29+
for handler in self.access_log.handlers:
30+
handler.setFormatter(formatter)
31+
32+
33+
logconfig_dict = {
34+
"loggers": {
35+
"gunicorn.error": {
36+
"level": "INFO",
37+
"handlers": ["console"],
38+
"propagate": False,
39+
},
40+
"gunicorn.access": {
41+
"level": "INFO",
42+
"handlers": ["console"],
43+
"propagate": False,
44+
},
45+
}
46+
}
47+
48+
logger_class = CustomGunicornLogger

requirements.in

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
aiodataloader
2+
ariadne==0.23
3+
ariadne_django==0.3.0
4+
celery>=5.3.6
5+
cerberus
6+
ddtrace
7+
Django>=4.2.16
8+
django-cors-headers
9+
django-csp
10+
django-dynamic-fixture
11+
django-filter
12+
django-model-utils
13+
django-postgres-extra>=2.0.8
14+
django-prometheus
15+
djangorestframework==3.15.2
16+
drf-spectacular
17+
drf-spectacular-sidecar
18+
elastic-apm
19+
factory-boy
20+
fakeredis
21+
freezegun
22+
https://github.com/codecov/opentelem-python/archive/refs/tags/v0.0.4a1.tar.gz#egg=codecovopentelem
23+
https://github.com/codecov/shared/archive/f96b72f47583a083e0c78ec7c7ff4da5d3ad6a19.tar.gz#egg=shared
24+
google-cloud-pubsub
25+
gunicorn>=22.0.0
26+
https://github.com/photocrowd/django-cursor-pagination/archive/f560902696b0c8509e4d95c10ba0d62700181d84.tar.gz
27+
idna>=3.7
28+
minio
29+
oauth2==1.9.0.post1
30+
opentelemetry-instrumentation-django>=0.45b0
31+
opentelemetry-sdk>=1.24.0
32+
opentracing
33+
polars==1.12.0
34+
pre-commit
35+
psycopg2
36+
PyJWT
37+
pydantic
38+
pytest>=7.2.0
39+
pytest-cov
40+
pytest-django
41+
pytest-mock
42+
pytest-asyncio
43+
python-dateutil
44+
python-json-logger
45+
python-redis-lock
46+
pytz
47+
redis
48+
regex
49+
requests
50+
sentry-sdk>=2.13.0
51+
sentry-sdk[celery]
52+
setproctitle
53+
simplejson
54+
starlette==0.40.0
55+
stripe>=9.6.0
56+
urllib3>=1.26.19
57+
vcrpy
58+
whitenoise
59+
django-autocomplete-light
60+
django-better-admin-arrayfield
61+
certifi>=2024.07.04

0 commit comments

Comments
 (0)