tina4-nodejs 3.11.1 → 3.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/core/src/graphql.ts +140 -23
package/package.json
CHANGED
|
@@ -480,8 +480,9 @@ export class GraphQL {
|
|
|
480
480
|
/**
|
|
481
481
|
* Execute a GraphQL query string.
|
|
482
482
|
*/
|
|
483
|
-
execute(query: string, variables?: Record<string, unknown>): GraphQLResult {
|
|
483
|
+
execute(query: string, variables?: Record<string, unknown>, context?: Record<string, unknown>): GraphQLResult {
|
|
484
484
|
const vars = variables ?? {};
|
|
485
|
+
const ctx = context ?? {};
|
|
485
486
|
const errors: Array<{ message: string; path?: string[] }> = [];
|
|
486
487
|
|
|
487
488
|
let doc: { definitions: ParsedOperation[] };
|
|
@@ -511,7 +512,10 @@ export class GraphQL {
|
|
|
511
512
|
const data: Record<string, unknown> = {};
|
|
512
513
|
|
|
513
514
|
for (const sel of op.selections) {
|
|
514
|
-
|
|
515
|
+
// Check directives (@skip, @include, @auth, @role, @guest)
|
|
516
|
+
if (!this.checkDirectives(sel.directives ?? [], vars, ctx)) continue;
|
|
517
|
+
|
|
518
|
+
const [value, errs] = this.resolveField(sel, resolvers, null, vars, ctx);
|
|
515
519
|
errors.push(...errs);
|
|
516
520
|
const key = sel.alias ?? sel.name;
|
|
517
521
|
data[key] = value;
|
|
@@ -841,22 +845,36 @@ export class GraphQL {
|
|
|
841
845
|
resolvers: Map<string, QueryConfig>,
|
|
842
846
|
parent: unknown,
|
|
843
847
|
variables: Record<string, unknown>,
|
|
848
|
+
context: Record<string, unknown> = {},
|
|
844
849
|
): [unknown, Array<{ message: string; path?: string[] }>] {
|
|
845
850
|
const errors: Array<{ message: string; path?: string[] }> = [];
|
|
846
851
|
const name = sel.name;
|
|
847
852
|
const args = this.resolveArgs(sel.args, variables);
|
|
848
853
|
|
|
854
|
+
// Check directives (@auth, @role, @guest, @skip, @include)
|
|
855
|
+
if (!this.checkDirectives(sel.directives ?? [], variables, context)) {
|
|
856
|
+
return [null, errors];
|
|
857
|
+
}
|
|
858
|
+
|
|
849
859
|
let value: unknown = undefined;
|
|
850
860
|
|
|
851
861
|
if (parent !== null && parent !== undefined) {
|
|
852
|
-
// Resolve from parent object
|
|
853
862
|
if (typeof parent === "object" && parent !== null) {
|
|
854
863
|
value = (parent as Record<string, unknown>)[name];
|
|
855
864
|
}
|
|
856
865
|
} else if (resolvers.has(name)) {
|
|
857
866
|
const config = resolvers.get(name)!;
|
|
867
|
+
|
|
868
|
+
// Input validation
|
|
869
|
+
const validationErrors = this.validateArgs(args, config.args ?? {}, name);
|
|
870
|
+
if (validationErrors.length > 0) {
|
|
871
|
+
return [null, validationErrors];
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Inject sub-selections into context for DataLoader/eager-loading
|
|
875
|
+
const ctx = { ...context, __selections: sel.selections ?? [] };
|
|
858
876
|
try {
|
|
859
|
-
value = config.resolver(null, args,
|
|
877
|
+
value = config.resolver(null, args, ctx);
|
|
860
878
|
} catch (e: unknown) {
|
|
861
879
|
const message = e instanceof Error ? e.message : String(e);
|
|
862
880
|
errors.push({ message, path: [name] });
|
|
@@ -864,45 +882,30 @@ export class GraphQL {
|
|
|
864
882
|
}
|
|
865
883
|
}
|
|
866
884
|
|
|
867
|
-
// If no sub-selections, return the scalar value
|
|
868
885
|
if (!sel.selections || sel.selections.length === 0) {
|
|
869
886
|
return [value, errors];
|
|
870
887
|
}
|
|
871
888
|
|
|
872
|
-
// Handle list types
|
|
873
889
|
if (Array.isArray(value)) {
|
|
874
890
|
const result: Record<string, unknown>[] = [];
|
|
875
891
|
for (const item of value) {
|
|
876
892
|
const obj: Record<string, unknown> = {};
|
|
877
893
|
for (const subSel of sel.selections) {
|
|
878
|
-
const [subVal, subErrs] = this.resolveField(
|
|
879
|
-
subSel,
|
|
880
|
-
new Map(),
|
|
881
|
-
item,
|
|
882
|
-
variables,
|
|
883
|
-
);
|
|
894
|
+
const [subVal, subErrs] = this.resolveField(subSel, new Map(), item, variables, context);
|
|
884
895
|
errors.push(...subErrs);
|
|
885
|
-
|
|
886
|
-
obj[key] = subVal;
|
|
896
|
+
obj[subSel.alias ?? subSel.name] = subVal;
|
|
887
897
|
}
|
|
888
898
|
result.push(obj);
|
|
889
899
|
}
|
|
890
900
|
return [result, errors];
|
|
891
901
|
}
|
|
892
902
|
|
|
893
|
-
// Handle object types
|
|
894
903
|
if (value !== null && value !== undefined) {
|
|
895
904
|
const obj: Record<string, unknown> = {};
|
|
896
905
|
for (const subSel of sel.selections) {
|
|
897
|
-
const [subVal, subErrs] = this.resolveField(
|
|
898
|
-
subSel,
|
|
899
|
-
new Map(),
|
|
900
|
-
value,
|
|
901
|
-
variables,
|
|
902
|
-
);
|
|
906
|
+
const [subVal, subErrs] = this.resolveField(subSel, new Map(), value, variables, context);
|
|
903
907
|
errors.push(...subErrs);
|
|
904
|
-
|
|
905
|
-
obj[key] = subVal;
|
|
908
|
+
obj[subSel.alias ?? subSel.name] = subVal;
|
|
906
909
|
}
|
|
907
910
|
return [obj, errors];
|
|
908
911
|
}
|
|
@@ -931,6 +934,120 @@ export class GraphQL {
|
|
|
931
934
|
}
|
|
932
935
|
return resolved;
|
|
933
936
|
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Check directives: @skip, @include, @auth, @role, @guest.
|
|
940
|
+
* Returns true if the field should be included, false to skip.
|
|
941
|
+
*/
|
|
942
|
+
private checkDirectives(
|
|
943
|
+
directives: Array<{ name: string; args: Record<string, unknown> }>,
|
|
944
|
+
variables: Record<string, unknown>,
|
|
945
|
+
context: Record<string, unknown> = {},
|
|
946
|
+
): boolean {
|
|
947
|
+
for (const d of directives) {
|
|
948
|
+
let val = d.args?.if;
|
|
949
|
+
if (typeof val === "object" && val !== null && "$var" in val) {
|
|
950
|
+
val = variables[(val as { $var: string }).$var];
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (d.name === "skip" && val) return false;
|
|
954
|
+
if (d.name === "include" && !val) return false;
|
|
955
|
+
|
|
956
|
+
// Auth: @auth — requires any authenticated user
|
|
957
|
+
if (d.name === "auth" && !context.user) return false;
|
|
958
|
+
|
|
959
|
+
// Auth: @role(role: "admin") — requires specific role
|
|
960
|
+
if (d.name === "role") {
|
|
961
|
+
const required = d.args?.role;
|
|
962
|
+
const user = context.user as Record<string, unknown> | undefined;
|
|
963
|
+
const actual = user?.role ?? context.role;
|
|
964
|
+
if (!required || actual !== required) return false;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Auth: @guest — only for unauthenticated
|
|
968
|
+
if (d.name === "guest" && context.user) return false;
|
|
969
|
+
}
|
|
970
|
+
return true;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Validate resolved args against declared types.
|
|
975
|
+
*/
|
|
976
|
+
private validateArgs(
|
|
977
|
+
args: Record<string, unknown>,
|
|
978
|
+
argConfigs: Record<string, string>,
|
|
979
|
+
fieldName: string,
|
|
980
|
+
): Array<{ message: string; path?: string[] }> {
|
|
981
|
+
const errors: Array<{ message: string; path?: string[] }> = [];
|
|
982
|
+
|
|
983
|
+
for (const [argName, declaredType] of Object.entries(argConfigs)) {
|
|
984
|
+
const parsed = GraphQLType.parse(declaredType);
|
|
985
|
+
const value = args[argName];
|
|
986
|
+
const isNonNull = parsed.kind === "non_null";
|
|
987
|
+
const innerType = isNonNull ? parsed.ofType! : parsed;
|
|
988
|
+
const baseName = innerType.kind === "list" ? "list" : innerType.name;
|
|
989
|
+
|
|
990
|
+
if (isNonNull && (value === null || value === undefined || value === "")) {
|
|
991
|
+
errors.push({
|
|
992
|
+
message: `Argument '${argName}' on field '${fieldName}' is required (type: ${declaredType})`,
|
|
993
|
+
path: [fieldName],
|
|
994
|
+
});
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (value === null || value === undefined) continue;
|
|
999
|
+
|
|
1000
|
+
if (baseName === "list" && Array.isArray(value)) {
|
|
1001
|
+
const itemType = innerType.ofType;
|
|
1002
|
+
if (itemType) {
|
|
1003
|
+
const itemName = itemType.kind === "non_null" ? (itemType.ofType?.name ?? "String") : itemType.name;
|
|
1004
|
+
for (let i = 0; i < value.length; i++) {
|
|
1005
|
+
if (!this.coerceValue(value[i], itemName)) {
|
|
1006
|
+
errors.push({
|
|
1007
|
+
message: `Argument '${argName}[${i}]' on field '${fieldName}' expected ${itemName}, got ${typeof value[i]}`,
|
|
1008
|
+
path: [fieldName],
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (GraphQLType.SCALARS.includes(baseName)) {
|
|
1017
|
+
if (!this.coerceValue(value, baseName)) {
|
|
1018
|
+
errors.push({
|
|
1019
|
+
message: `Argument '${argName}' on field '${fieldName}' expected type ${baseName}, got ${typeof value}`,
|
|
1020
|
+
path: [fieldName],
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return errors;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
private coerceValue(value: unknown, typeName: string): boolean {
|
|
1030
|
+
if (typeName === "String" || typeName === "ID") {
|
|
1031
|
+
return typeof value === "string" || typeof value === "number";
|
|
1032
|
+
}
|
|
1033
|
+
if (typeName === "Int") {
|
|
1034
|
+
if (typeof value === "boolean") return false;
|
|
1035
|
+
if (typeof value === "number") return Number.isInteger(value);
|
|
1036
|
+
if (typeof value === "string") return /^-?\d+$/.test(value);
|
|
1037
|
+
return false;
|
|
1038
|
+
}
|
|
1039
|
+
if (typeName === "Float") {
|
|
1040
|
+
if (typeof value === "boolean") return false;
|
|
1041
|
+
if (typeof value === "number") return true;
|
|
1042
|
+
if (typeof value === "string") return !isNaN(parseFloat(value));
|
|
1043
|
+
return false;
|
|
1044
|
+
}
|
|
1045
|
+
if (typeName === "Boolean") {
|
|
1046
|
+
return typeof value === "boolean" || value === 0 || value === 1
|
|
1047
|
+
|| value === "true" || value === "false";
|
|
1048
|
+
}
|
|
1049
|
+
return true;
|
|
1050
|
+
}
|
|
934
1051
|
}
|
|
935
1052
|
|
|
936
1053
|
/**
|