Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1/* eslint-disable max-lines */
2import { AuthenticationError } from 'apollo-server-express';
3
4import { isConditionSet } from '../../condition_evaluator/condition.js';
5import {
6 getNameForDerivedField,
7 type DerivedFieldSpec as TDerivedFieldSpec,
8} from '../../services/derivedFieldsService/index.js';
9import {
10 CoopInput,
11 type ConditionInput,
12 type LeafCondition,
13} from '../../services/moderationConfigService/index.js';
14import { isSignalId } from '../../services/signalsService/index.js';
15import { jsonParse } from '../../utils/encoding.js';
16import { isCoopErrorOfType } from '../../utils/errors.js';
17import { assertUnreachable } from '../../utils/misc.js';
18import {
19 type GQLConditionResolvers,
20 type GQLConditionWithResultResolvers,
21 type GQLContentRuleResolvers,
22 type GQLLeafConditionResolvers,
23 type GQLLeafConditionWithResultResolvers,
24 type GQLMutationResolvers,
25 type GQLQueryResolvers,
26 type GQLRuleResolvers,
27 type GQLUserRuleResolvers,
28} from '../generated.js';
29import { type Context, type ResolverMap } from '../resolvers.js';
30import { gqlErrorResult, gqlSuccessResult } from '../utils/gqlResult.js';
31
32const typeDefs = /* GraphQL */ `
33 type Query {
34 rule(id: ID!): Rule
35 }
36
37 type Mutation {
38 createContentRule(
39 input: CreateContentRuleInput!
40 ): CreateContentRuleResponse!
41 updateContentRule(
42 input: UpdateContentRuleInput!
43 ): UpdateContentRuleResponse!
44 createUserRule(input: CreateUserRuleInput!): CreateUserRuleResponse!
45 updateUserRule(input: UpdateUserRuleInput!): UpdateUserRuleResponse!
46 deleteRule(id: ID!): Boolean
47 }
48
49 interface Rule {
50 id: ID!
51 parentId: ID
52 name: String!
53 creator: User!
54 createdAt: String!
55 updatedAt: String!
56 description: String
57 status: RuleStatus!
58 conditionSet: ConditionSet!
59 actions: [Action!]!
60 policies: [Policy!]!
61 tags: [String]
62 # GraphQL doesn't support BIGINT, so this must be a Float
63 maxDailyActions: Float
64 expirationTime: String
65 backtests(ids: [ID!]): [Backtest!]!
66 insights: RuleInsights!
67 }
68
69 type ContentRule implements Rule {
70 id: ID!
71 parentId: ID
72 name: String!
73 creator: User!
74 createdAt: String!
75 updatedAt: String!
76 description: String
77 status: RuleStatus!
78 conditionSet: ConditionSet!
79 actions: [Action!]!
80 policies: [Policy!]!
81 tags: [String]
82 maxDailyActions: Float
83 expirationTime: String
84 backtests(ids: [ID!]): [Backtest!]!
85 insights: RuleInsights!
86 # Content rule specific fields
87 itemTypes: [ItemType!]!
88 }
89
90 type UserRule implements Rule {
91 id: ID!
92 parentId: ID
93 name: String!
94 creator: User!
95 createdAt: String!
96 updatedAt: String!
97 description: String
98 status: RuleStatus!
99 conditionSet: ConditionSet!
100 actions: [Action!]!
101 policies: [Policy!]!
102 tags: [String]
103 maxDailyActions: Float
104 expirationTime: String
105 backtests(ids: [ID!]): [Backtest!]!
106 insights: RuleInsights!
107 }
108
109 # ConditionSetWithResult holds all the info we need to display the rule's
110 # conditions, and each of their results, on a single piece of content.
111 # It's identical to the backend TS type ConditionSetWithResultAsLogged,
112 # because it only has the subset of the fields that we bother to store in
113 # the data warehouse about a rule execution.
114 type ConditionSetWithResult {
115 conjunction: ConditionConjunction
116 conditions: [ConditionWithResult!]!
117 # If undefined, it means the condition set was skipped.
118 # OR, the condition was run before we started logging results.
119 result: ConditionResult
120 }
121
122 union ConditionWithResult = LeafConditionWithResult | ConditionSetWithResult
123
124 type LeafConditionWithResult {
125 input: ConditionInputField!
126 signal: Signal
127 matchingValues: MatchingValues
128 comparator: ValueComparator
129 threshold: StringOrFloat
130 # If undefined, it means the condition was skipped.
131 result: ConditionResult
132 }
133
134 enum RuleStatus {
135 BACKGROUND
136 DRAFT
137 DEPRECATED
138 EXPIRED
139 LIVE
140 ARCHIVED
141 }
142
143 enum ConditionConjunction {
144 AND
145 OR
146 XOR
147 }
148
149 type ConditionSet {
150 conjunction: ConditionConjunction!
151 conditions: [Condition!]!
152 }
153
154 union Condition = ConditionSet | LeafCondition
155
156 type LeafCondition {
157 input: ConditionInputField!
158 signal: Signal
159 matchingValues: MatchingValues
160 comparator: ValueComparator
161 threshold: StringOrFloat
162 }
163
164 # Ideally, this should be a union (mirroring ConditionInput on the backend),
165 # but Gql doesn't support union input types, so this is easier.
166 # !! IMPORTANT: Keep this in sync with ConditionInputFieldInput
167 type ConditionInputField {
168 type: ConditionInputInputType!
169 name: CoopInputOrString
170 contentTypeId: String
171 contentTypeIds: [String!]
172 spec: DerivedFieldSpec
173 }
174
175 enum ConditionInputInputType {
176 USER_ID
177 CONTENT_FIELD
178 CONTENT_COOP_INPUT
179 FULL_ITEM
180 CONTENT_DERIVED_FIELD
181 }
182
183 type MatchingValues {
184 strings: [String!]
185 textBankIds: [String!]
186 locations: [LocationArea!]
187 locationBankIds: [String!]
188 imageBankIds: [String!]
189 }
190
191 type DerivedFieldSpec {
192 source: DerivedFieldSource!
193 derivationType: DerivedFieldDerivationType!
194 }
195
196 input DerivedFieldSpecInput {
197 source: DerivedFieldSourceInput!
198 derivationType: DerivedFieldDerivationType!
199 }
200
201 enum DerivedFieldDerivationType {
202 ENGLISH_TRANSLATION
203 VIDEO_TRANSCRIPTION
204 }
205
206 union DerivedFieldSource =
207 DerivedFieldFullItemSource
208 | DerivedFieldFieldSource
209 | DerivedFieldCoopInputSource
210
211 # NB: This is a more cumbersome approach compared to what we've used
212 # previously to handle input union types (e.g., in ConditionInputField).
213 # However, it is more type safe and is forward compatible with
214 # https://github.com/graphql/graphql-spec/pull/825, which is nice.
215 input DerivedFieldSourceInput {
216 fullItem: DerivedFieldFullItemSourceInput
217 contentField: DerivedFieldFieldSourceInput
218 contentCoopInput: DerivedFieldCoopInputSourceInput
219 }
220
221 # Re the _ field, see https://github.com/graphql/graphql-spec/issues/568 and
222 # https://github.com/graphql/graphql-spec/pull/825#issuecomment-1182979316
223 type DerivedFieldFullItemSource {
224 _: Boolean
225 }
226 input DerivedFieldFullItemSourceInput {
227 _: Boolean
228 }
229
230 type DerivedFieldFieldSource {
231 name: String!
232 contentTypeId: String!
233 }
234 input DerivedFieldFieldSourceInput {
235 name: String!
236 contentTypeId: String!
237 }
238
239 type DerivedFieldCoopInputSource {
240 name: CoopInput!
241 }
242 input DerivedFieldCoopInputSourceInput {
243 name: CoopInput!
244 }
245
246 enum CoopInput {
247 ALL_TEXT
248 ANY_IMAGE
249 ANY_GEOHASH
250 ANY_VIDEO
251 AUTHOR_USER
252 POLICY_ID
253 SOURCE
254 }
255
256 input ConditionMatchingValuesInput {
257 strings: [String!]
258 textBankIds: [String!]
259 locations: [LocationAreaInput!]
260 locationBankIds: [String!]
261 imageBankIds: [String!]
262 }
263
264 # Allows user to see the evaluation result of each condition
265 type ConditionResult {
266 outcome: ConditionOutcome!
267
268 # Represents computed results from a single condition - e.g.
269 # 3P model scores, similarity scores, bool evaluations, etc.
270 # It has to be a String because there is no concept of the 'any'
271 # type (or even union types like string | number) in GraphQL. So
272 # no matter what the score type actually is, we pass it to the client
273 # as a string
274 score: String
275
276 # Represents the value on which the input was matched against for
277 # signals that require matching values. For example, this would be
278 # the matched regex in a regex bank that triggered the rule.
279 matchedValue: String
280 }
281
282 enum ConditionOutcome {
283 PASSED
284 FAILED
285 INAPPLICABLE
286 ERRORED
287 }
288
289 input CreateContentRuleInput {
290 name: String!
291 description: String
292 status: RuleStatus!
293 contentTypeIds: [ID!]!
294 conditionSet: ConditionSetInput!
295 actionIds: [ID!]!
296 policyIds: [ID!]!
297 tags: [String!]!
298 # GraphQL doesn't support BIGINT, so this must be a Float
299 maxDailyActions: Float
300 expirationTime: DateTime
301 parentId: ID
302 }
303
304 input UpdateContentRuleInput {
305 id: ID!
306 name: String
307 description: String
308 status: RuleStatus
309 contentTypeIds: [ID!]
310 conditionSet: ConditionSetInput
311 actionIds: [ID!]
312 policyIds: [ID!]
313 tags: [String!]
314 # GraphQL doesn't support BIGINT, so this must be a Float
315 maxDailyActions: Float
316 expirationTime: DateTime
317 cancelRunningBacktests: Boolean
318 parentId: ID
319 }
320
321 input CreateUserRuleInput {
322 name: String!
323 description: String
324 status: RuleStatus!
325 conditionSet: ConditionSetInput!
326 actionIds: [ID!]!
327 policyIds: [ID!]!
328 tags: [String!]!
329 # GraphQL doesn't support BIGINT, so this must be a Float
330 maxDailyActions: Float
331 expirationTime: DateTime
332 parentId: ID
333 }
334
335 input UpdateUserRuleInput {
336 id: ID!
337 name: String
338 description: String
339 status: RuleStatus
340 conditionSet: ConditionSetInput
341 actionIds: [ID!]
342 policyIds: [ID!]
343 tags: [String!]
344 # GraphQL doesn't support BIGINT, so this must be a Float
345 maxDailyActions: Float
346 expirationTime: DateTime
347 cancelRunningBacktests: Boolean
348 parentId: ID
349 }
350
351 input ConditionSetInput {
352 conjunction: ConditionConjunction!
353 conditions: [ConditionInput!]!
354 }
355
356 # This needs to be able to represent a ConditionSet or a LeafCondition,
357 # but Graphql union input type support is WIP (https://github.com/graphql/graphql-spec/pull/825),
358 # so we have fields here for both.
359 input ConditionInput {
360 # LeafCondition fields
361 input: ConditionInputFieldInput
362 signal: ConditionInputSignalInput
363 matchingValues: ConditionMatchingValuesInput
364 comparator: ValueComparator
365 threshold: StringOrFloat
366
367 # ConditionSet fields
368 conjunction: ConditionConjunction
369 conditions: [ConditionInput!]
370 }
371
372 input ConditionInputSignalInput {
373 id: ID! # JsonOf<SignalId>
374 type: String!
375 name: String
376 subcategory: String
377 args: SignalArgsInput
378 }
379
380 input SignalSubcategoryInput {
381 name: String
382 options: [SignalSubcategoryOptionInput]
383 }
384
385 input SignalSubcategoryOptionInput {
386 name: String
387 description: String
388 }
389
390 input DisabledInfoInput {
391 disabled: Boolean
392 disabledMessage: String
393 }
394
395 # Ideally, this should be a union (mirroring ConditionInput on the backend),
396 # but Gql doesn't support union input types, so this is easier.
397 #
398 # !! IMPORTANT: Keep this in sync with ConditionInputField
399 input ConditionInputFieldInput {
400 type: ConditionInputInputType!
401 name: CoopInputOrString
402 contentTypeId: String
403 contentTypeIds: [String!]
404 spec: DerivedFieldSpecInput
405 }
406
407 type RuleNameExistsError implements Error {
408 title: String!
409 status: Int!
410 type: [String!]!
411 pointer: String
412 detail: String
413 requestId: String
414 }
415
416 input SignalArgsInput {
417 AGGREGATION: AggregationSignalArgsInput
418 }
419
420 input AggregationSignalArgsInput {
421 aggregationClause: AggregationClauseInput!
422 }
423
424 input AggregationClauseInput {
425 aggregation: AggregationInput!
426 conditionSet: ConditionSetInput
427 groupBy: [ConditionInputFieldInput!]!
428 window: WindowConfigurationInput!
429 }
430
431 input WindowConfigurationInput {
432 sizeMs: Int!
433 hopMs: Int!
434 }
435
436 input AggregationInput {
437 type: AggregationType!
438 }
439
440
441
442 type RuleHasRunningBacktestsError implements Error {
443 title: String!
444 status: Int!
445 type: [String!]!
446 pointer: String
447 detail: String
448 requestId: String
449 }
450
451 union CreateContentRuleResponse =
452 MutateContentRuleSuccessResponse
453 | RuleNameExistsError
454
455 union UpdateContentRuleResponse =
456 MutateContentRuleSuccessResponse
457 | RuleNameExistsError
458 | RuleHasRunningBacktestsError
459 | NotFoundError
460
461 union CreateUserRuleResponse =
462 MutateUserRuleSuccessResponse
463 | RuleNameExistsError
464
465 union UpdateUserRuleResponse =
466 MutateUserRuleSuccessResponse
467 | RuleNameExistsError
468 | RuleHasRunningBacktestsError
469 | NotFoundError
470
471 type MutateContentRuleSuccessResponse {
472 data: ContentRule!
473 }
474
475 type MutateUserRuleSuccessResponse {
476 data: UserRule!
477 }
478`;
479
480const Query: GQLQueryResolvers = {
481 async rule(_, { id }, { dataSources, getUser }) {
482 const user = await getUser();
483 if (user == null) {
484 throw new AuthenticationError('Authenticated user required');
485 }
486
487 return dataSources.ruleAPI.getGraphQLRuleFromId(id, user.orgId);
488 },
489};
490
491const Mutation: GQLMutationResolvers = {
492 async createContentRule(_, params, context) {
493 const user = context.getUser();
494 if (user == null) {
495 throw new AuthenticationError('Authenticated user required');
496 }
497
498 try {
499 const rule = await context.dataSources.ruleAPI.createContentRule(
500 params.input,
501 user.id,
502 user.orgId,
503 );
504
505 return gqlSuccessResult(
506 { data: rule },
507 'MutateContentRuleSuccessResponse',
508 );
509 } catch (e: unknown) {
510 if (isCoopErrorOfType(e, 'RuleNameExistsError')) {
511 return gqlErrorResult(e, `/input/name`);
512 }
513
514 throw e;
515 }
516 },
517 async updateContentRule(_, params, context) {
518 try {
519 const user = context.getUser();
520 if (user == null) {
521 throw new AuthenticationError('Authenticated user required');
522 }
523
524 const rule = await context.dataSources.ruleAPI.updateContentRule({
525 input: params.input,
526 orgId: user.orgId,
527 });
528 return gqlSuccessResult(
529 { data: rule },
530 'MutateContentRuleSuccessResponse',
531 );
532 } catch (e: unknown) {
533 if (isCoopErrorOfType(e, 'RuleNameExistsError')) {
534 return gqlErrorResult(e, `/input/name`);
535 }
536
537 if (isCoopErrorOfType(e, 'RuleHasRunningBacktestsError')) {
538 return gqlErrorResult(e, `/input/cancelRunningBacktests`);
539 }
540
541 if (isCoopErrorOfType(e, 'NotFoundError')) {
542 return gqlErrorResult(e, `/input/id`);
543 }
544
545 throw e;
546 }
547 },
548 async createUserRule(_, params, context) {
549 const user = context.getUser();
550 if (user == null) {
551 throw new AuthenticationError('Authenticated user required');
552 }
553
554 try {
555 const rule = await context.dataSources.ruleAPI.createUserRule(
556 params.input,
557 user.id,
558 user.orgId,
559 );
560
561 return gqlSuccessResult({ data: rule }, 'MutateUserRuleSuccessResponse');
562 } catch (e: unknown) {
563 if (isCoopErrorOfType(e, 'RuleNameExistsError')) {
564 return gqlErrorResult(e, `/input/name`);
565 }
566
567 throw e;
568 }
569 },
570 async updateUserRule(_, params, context) {
571 const user = context.getUser();
572 if (user == null) {
573 throw new AuthenticationError('Authenticated user required');
574 }
575
576 try {
577 const rule = await context.dataSources.ruleAPI.updateUserRule({
578 input: params.input,
579 orgId: user.orgId,
580 });
581 return gqlSuccessResult({ data: rule }, 'MutateUserRuleSuccessResponse');
582 } catch (e: unknown) {
583 if (isCoopErrorOfType(e, 'RuleNameExistsError')) {
584 return gqlErrorResult(e, `/input/name`);
585 }
586
587 if (isCoopErrorOfType(e, 'RuleHasRunningBacktestsError')) {
588 return gqlErrorResult(e, `/input/cancelRunningBacktests`);
589 }
590
591 if (isCoopErrorOfType(e, 'NotFoundError')) {
592 return gqlErrorResult(e, `/input/id`);
593 }
594
595 throw e;
596 }
597 },
598 async deleteRule(_, params, context) {
599 const user = context.getUser();
600 if (user == null) {
601 throw new AuthenticationError('Authenticated user required');
602 }
603
604 return context.dataSources.ruleAPI.deleteRule({
605 id: params.id,
606 orgId: user.orgId,
607 });
608 },
609};
610
611const ConditionInputField: ResolverMap<ConditionInput> = {
612 async name(conditionInputField, _, context) {
613 const user = context.getUser();
614 if (user == null) {
615 throw new AuthenticationError('Authenticated user required');
616 }
617
618 if (conditionInputField.type !== 'CONTENT_DERIVED_FIELD') {
619 return (conditionInputField as { name?: string }).name ?? null;
620 } else {
621 return getNameForDerivedField(conditionInputField.spec);
622 }
623 },
624};
625
626const Rule: GQLRuleResolvers = {
627 async __resolveType(rule) {
628 switch (rule.ruleType) {
629 case 'CONTENT':
630 return 'ContentRule';
631 case 'USER':
632 return 'UserRule';
633 default:
634 assertUnreachable(rule.ruleType);
635 }
636 },
637};
638
639const ContentRule: GQLContentRuleResolvers = {
640 async creator(rule, _, context) {
641 const user = context.getUser();
642 if (user == null) {
643 throw new AuthenticationError('Authenticated user required');
644 }
645
646 return rule.getCreator();
647 },
648 async itemTypes(rule, _, { services, getUser }) {
649 const user = getUser();
650 if (user == null) {
651 throw new AuthenticationError('Authenticated user required');
652 }
653 return services.ModerationConfigService.getItemTypesForRule({
654 orgId: user.orgId,
655 ruleId: rule.id,
656 });
657 },
658 async actions(rule, _, context) {
659 const user = context.getUser();
660 if (user == null) {
661 throw new AuthenticationError('Authenticated user required');
662 }
663
664 return rule.getActions();
665 },
666 async policies(rule, _, context) {
667 const user = context.getUser();
668 if (user == null) {
669 throw new AuthenticationError('Authenticated user required');
670 }
671
672 return rule.getPolicies();
673 },
674 async backtests(rule, args, context) {
675 const user = context.getUser();
676 if (user == null) {
677 throw new AuthenticationError('Authenticated user required');
678 }
679
680 const { ids } = args;
681 return context.dataSources.ruleAPI.getBacktestsForRule(rule.id, ids);
682 },
683 async insights(rule, _args, context) {
684 // just return the rule, which then becomes the parent/source for the
685 // insights resolver. But verify the rule is owned by the user's org
686 const user = context.getUser();
687 if (user == null) {
688 throw new AuthenticationError('User required');
689 }
690
691 if (rule.orgId !== user.orgId) {
692 throw new Error("Rule does not belong to user's org");
693 }
694
695 return rule;
696 },
697};
698
699const UserRule: GQLUserRuleResolvers = {
700 async creator(rule, _, context) {
701 const user = context.getUser();
702 if (user == null) {
703 throw new AuthenticationError('Authenticated user required');
704 }
705
706 return rule.getCreator();
707 },
708 async actions(rule, _, context) {
709 const user = context.getUser();
710 if (user == null) {
711 throw new AuthenticationError('Authenticated user required');
712 }
713
714 return rule.getActions();
715 },
716 async policies(rule, _, context) {
717 const user = context.getUser();
718 if (user == null) {
719 throw new AuthenticationError('Authenticated user required');
720 }
721
722 return rule.getPolicies();
723 },
724 async backtests(rule, args, context) {
725 const user = context.getUser();
726 if (user == null) {
727 throw new AuthenticationError('Authenticated user required');
728 }
729
730 const { ids } = args;
731 return context.dataSources.ruleAPI.getBacktestsForRule(rule.id, ids);
732 },
733 async insights(rule, _args, context) {
734 // just return the rule, which then becomes the parent/source for the
735 // insights resolver. But verify the rule is owned by the user's org
736 const user = context.getUser();
737 if (user == null) {
738 throw new AuthenticationError('Authenticated user required');
739 }
740
741 if (rule.orgId !== user.orgId) {
742 throw new Error("Rule does not belong to user's org");
743 }
744
745 return rule;
746 },
747};
748
749const Condition: GQLConditionResolvers = {
750 __resolveType(condition) {
751 return isConditionSet(condition) ? 'ConditionSet' : 'LeafCondition';
752 },
753};
754
755const ConditionWithResult: GQLConditionWithResultResolvers = {
756 __resolveType(conditionWithResult) {
757 return 'conditions' in conditionWithResult
758 ? 'ConditionSetWithResult'
759 : 'LeafConditionWithResult';
760 },
761};
762
763const signalForStoredCondition = async (
764 condition: Pick<LeafCondition, 'signal'>,
765 _args: unknown,
766 context: Context,
767) => {
768 const user = context.getUser();
769 if (!condition.signal || !user) return null;
770
771 const signalId = jsonParse(condition.signal.id);
772
773 // Handle case of a stored signal id that's no longer valid (e.g., cuz the
774 // underlying signal's been deleted.)
775 if (!isSignalId(signalId)) return null;
776
777 const signal = await context.services.SignalsService.getSignal({
778 signalId,
779 // TODO: In the context of an individual condition, it's pretty hard
780 // to get the org that owns the rule that contains the condition. We
781 // should make that easier (how?) but, for now, we can just assume
782 // that the rule is owned by the same org as the user.
783 orgId: user.orgId,
784 });
785 if (!signal) {
786 return null;
787 }
788
789 return {
790 ...signal,
791 subcategory: condition.signal.subcategory ?? undefined,
792 args: condition.signal.args,
793 };
794};
795
796const LeafCondition: GQLLeafConditionResolvers = {
797 signal: signalForStoredCondition,
798};
799
800const LeafConditionWithResult: GQLLeafConditionWithResultResolvers = {
801 signal: signalForStoredCondition,
802};
803
804const DerivedFieldSource: ResolverMap<TDerivedFieldSpec['source']> = {
805 __resolveType(derivedFieldSource) {
806 const { type } = derivedFieldSource;
807 switch (type) {
808 case 'FULL_ITEM':
809 return 'DerivedFieldFullItemSource';
810 case 'CONTENT_FIELD':
811 return 'DerivedFieldFieldSource';
812 case 'CONTENT_COOP_INPUT':
813 return 'DerivedFieldCoopInputSource';
814 default:
815 assertUnreachable(type);
816 }
817 },
818};
819
820const resolvers = {
821 Query,
822 Mutation,
823 Rule,
824 ContentRule,
825 UserRule,
826 Condition,
827 LeafCondition,
828 ConditionWithResult,
829 LeafConditionWithResult,
830 DerivedFieldSource,
831 ConditionInputField,
832 // Use the CoopInput enum directly as the resolver, to map the incoming GQL
833 // names, which happen to match the TS enum case names, to the underlying string
834 // values.
835 //
836 // NB: In some places, the GQL API outputs what's internally a CoopInput,
837 // but the GQL Schema doesn't yet declare it as such. Accordingly, those values
838 // won't go through this resolver, and will end up inconsistent (making their
839 // way to the frontend as the internal TS value, rather than as the enum case).
840 // That's ok, because it's not a backwards compatibility break; we'll just
841 // gradually update the schema to actually use the enum.
842 CoopInput,
843};
844
845export { typeDefs, resolvers };