Module: Axn::Core::Contract::ClassMethods

Includes:
Redaction, ShapeDeclaration
Defined in:
lib/axn/core/contract.rb

Constant Summary

Constants included from Redaction

Redaction::SENSITIVE_FILTERED_MASK

Instance Method Summary collapse

Methods included from Redaction

#_build_instance_filter, #_context_slice, #_has_dynamic_sensitive_fields?, #_mask_unfilterable_shape_value, #_mask_unfilterable_shapes, #_resolve_sensitive_value, #_sensitive_ambient_shape_paths, #_sensitive_member_names, #inspection_filter, #sensitive_fields

Instance Method Details

#_declared_fields(direction) ⇒ Object

Raises:

  • (ArgumentError)


537
538
539
540
541
542
543
544
545
546
547
# File 'lib/axn/core/contract.rb', line 537

def _declared_fields(direction)
  raise ArgumentError, "Invalid direction: #{direction}" unless direction.nil? || %i[inbound outbound].include?(direction)

  configs = case direction
            when :inbound then internal_field_configs
            when :outbound then external_field_configs
            else (internal_field_configs + external_field_configs)
            end

  configs.map(&:field)
end

#expects(*fields, on: nil, allow_blank: false, allow_nil: false, allow_empty: nil, optional: false, default: nil, preprocess: nil, sensitive: false, as: nil, prefix: nil, user_facing: false, method_call: false, &block) ⇒ Object

rubocop:disable Metrics/ParameterLists



370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
# File 'lib/axn/core/contract.rb', line 370

def expects(
  *fields,
  on: nil,
  allow_blank: false,
  allow_nil: false,
  allow_empty: nil,
  optional: false,
  default: nil,
  preprocess: nil,
  sensitive: false,
  as: nil,
  prefix: nil,
  user_facing: false,
  method_call: false,
  **,
  &block
)
  # Canonicalize the wire key to a symbol up front so everything downstream — config.field,
  # reader names, duplicate detection, the inbound read path — is symbol-keyed by construction.
  # `expects "note"` and `expects :note` are the same field; a dotted subfield key (`"a.b"`)
  # symbolizes harmlessly (it's only ever compared/split via `.to_s`). See PRO-2790.
  fields = _canonical_field_names!(fields, kind: "a field name", names: "an inbound field")

  # A subfield's ROUTE is canonicalized on the same terms, and here — before the first guard reads it.
  # A route is judged as written (its root must name a declared reader; `_duplicate_fields` keys a config
  # by it) and then split again by every consumer: `SubfieldTree`, `resolve_parent`'s recipe, the ambient
  # checks, the executor's parent memo. So a caller value whose rendering answered differently on
  # successive reads had one layer judging one route while the tree built another — two subfields
  # silently landing on ONE node, merged as if they were two routes to one wire slot, where the same
  # declaration written honestly is a duplicate. A Symbol's `to_s` is Ruby's own, so afterwards every
  # read is the same answer, and no message renders a route by running the route's own code. Dotted
  # paths symbolize harmlessly, exactly as a dotted `on:` always did: they are only ever split via `to_s`.
  #
  # Absent stays absent — `nil`, `false`, an empty or whitespace-only String and the empty Symbol all
  # mean "no route", which is the whole of what the `present?` this replaced answered — and that verdict
  # is reached WITHOUT running the route's own code, because `present?`/`blank?` are ActiveSupport
  # methods on Object that a String subclass overrides: one answering "blank" here and "present" to a
  # later reader skipped this line entirely and was stored raw, reinstating the split this
  # canonicalization exists to close. Absent canonicalizes to `nil` rather than being left as written,
  # so that every reader below is asking nil-or-Symbol — otherwise the SAME split reopens one line
  # down, with this deciding "absent" and a `present?` on the caller's own value deciding to route.
  #
  # A supplied route that is not a name at all is rejected here rather than left to `to_sym` (see
  # Contract.validate_name_option!): the option and the offending class are what the author needs, and
  # `NoMethodError` named neither.
  on = if Internal::NativeMethods.absent_value?(on)
         nil
       else
         # Canonicalized through the shared rule, which also holds the encoding of what `to_sym` ANSWERS —
         # this is the value every consumer then splits on `.`, and a wide one raised from the split.
         Contract.canonical_name!(on, option: "on:", names: "a parent reader",
                                      fix: "Pass the parent's name (dotted for a nested path), or omit `on:` " \
                                           "to declare a top-level field.",
                                      encoding_fix: "Name the parent in UTF-8 (or any other ASCII-compatible " \
                                                    "encoding).")
       end

  fields.each do |field|
    raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPECTATIONS.include?(field.to_s)
  end

  # A field's wire key always names a single key; the nested-path capability lives entirely in a
  # dotted `on:` (`expects :b, on: "a"`). A dotted field NAME is therefore never valid — reject it
  # unconditionally, pointed at the dotted-`on:` spelling (PRO-2926). A dotted `on:` VALUE is fine.
  _reject_dotted_field_name!(fields, on:)

  _validate_user_facing!(user_facing)

  # `method_call:` governs how a SUBFIELD's segment is resolved from its parent (invoke vs.
  # read). A top-level field reads its literal wire key from the context Hash — it never
  # method-dispatches — so `method_call: true` without `on:` could never take effect; reject
  # it rather than accept a silently-inert option (matching the ambient default:/coerce:
  # rejections). `method_call: false` is the default, so it's a harmless no-op anywhere.
  if method_call && on.nil?
    raise ArgumentError,
          "`method_call: true` is only meaningful on a subfield (declared with `on:`) — a top-level field " \
          "reads its wire key and never invokes a method. Add `on:` to name the parent, or drop `method_call:`."
  end

  reader_names = _resolve_reader_names(fields, as:, prefix:)
  _validate_reader_names!(reader_names)

  validations,  = _partition_field_options(fields, **)
  validations[:shape] = _build_shape(fields, validations:, &block) if block
  _snapshot_declared_shape!(validations, fields)

  # `on` is nil-or-Symbol by construction here (canonicalized above), so routing asks the canonical
  # value rather than re-deciding presence on whatever the caller passed.
  if on
    return _expects_subfields(*fields, on:, allow_blank:, allow_nil:, allow_empty:, optional:, default:, preprocess:, sensitive:, metadata:,
                                       reader_names:, user_facing:, method_call:, **validations)
  end

  _parse_field_configs(*fields, allow_blank:, allow_nil:, allow_empty:, optional:, default:, preprocess:, sensitive:, metadata:,
                                reader_names:, user_facing:, **validations).tap do |configs|
    _reject_duplicate_fields!(internal_field_configs, configs)
    # Declaring a top-level field can RE-ANCHOR existing subfields (a new root takes precedence over
    # a same-named subfield reader), so the resolved check runs here too rather than only where
    # subfields are declared.

    # Every declaration check has passed; NOW mutate the class (matching _expects_subfields'
    # validate-before-commit ordering), so a rescued declaration error never leaves the class
    # carrying an orphaned config or generated reader. Copy-on-write + freeze: `<<` would
    # mutate the superclass's contract, and identity-keyed caching relies on replacement.
    self.internal_field_configs = (internal_field_configs + configs).freeze
    _define_field_readers!(configs)
  end
end

#exposes(*fields, allow_blank: false, allow_nil: false, allow_empty: nil, optional: false, default: nil, sensitive: false, &block) ⇒ Object

rubocop:enable Metrics/ParameterLists



480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
# File 'lib/axn/core/contract.rb', line 480

def exposes(
  *fields,
  allow_blank: false,
  allow_nil: false,
  allow_empty: nil,
  optional: false,
  default: nil,
  sensitive: false,
  **,
  &block
)
  # Symbolize the wire key (see `expects`) so exposes shares the same symbol-keyed contract.
  fields = _canonical_field_names!(fields, kind: "an exposure name", names: "an outbound field")

  # Stays pre-build, unlike every other declared name: an exposed field name is a property in the
  # SERIALIZED BODY (`Values.serialize_exposed` iterates these configs and raises on an unrenderable
  # one) as well as in `output_schema`, so it must be rejected whatever the schema emits.
  _reject_unrenderable_field_names!(fields)

  fields.each do |field|
    raise ContractViolation::ReservedAttributeError, field if RESERVED_FIELD_NAMES_FOR_EXPOSURES.include?(field.to_s)
  end

  # exposes has no `on:`/subfields, so a dotted name has no valid meaning at all (see expects).
  _reject_dotted_field_name!(fields, on: nil, kind: "exposes")

  validations,  = _partition_field_options(fields, **)

  validations[:shape] = _build_shape(fields, validations:, outbound: true, &block) if block

  # Ahead of the `user_facing:` walk below so a member carrying both an unusable name and a rejected
  # option is reported as the naming defect it is. That ordering only governs these two walks over
  # resolved members: in the block form the option error surfaces first, raised inside
  # `_build_shape_member` while `_build_shape` above is still assembling the members.
  _snapshot_declared_shape!(validations, fields)

  # The block form rejects a `user_facing:` member inside `_build_shape_member` (above), but a
  # raw `shape:` kwarg supplies pre-built member objects that never route through it — so walk
  # the resolved members here to close that path too (see _reject_outbound_shape_user_facing!).
  _reject_outbound_shape_user_facing!(validations[:shape])

  _parse_field_configs(*fields, allow_blank:, allow_nil:, allow_empty:, optional:, default:, preprocess: nil, sensitive:, metadata:,
                                **validations).tap do |configs|
    if configs.any? { |c| c.validations.dig(:type, :coerce) }
      raise ArgumentError, "coerce: is not supported on exposes (outbound fields are serialized, not coerced)."
    end

    _reject_duplicate_fields!(external_field_configs, configs)
    # The outbound claim space. `exposes` has no `on:`, so there are no routes to resolve — but a shape
    # member (and a `Data` type's own members) still name properties under an exposure, and those pairs
    # collapse in `output_schema` exactly as their inbound counterparts do.

    # Copy-on-write + freeze (see internal_field_configs above).
    self.external_field_configs = (external_field_configs + configs).freeze
  end
end