label */ public static function operators() { return array( 'equals' => __( 'equals', 'hookbird' ), 'not_equals' => __( 'does not equal', 'hookbird' ), 'contains' => __( 'contains', 'hookbird' ), 'not_contains' => __( 'does not contain', 'hookbird' ), 'gt' => __( 'is greater than', 'hookbird' ), 'gte' => __( 'is at least', 'hookbird' ), 'lt' => __( 'is less than', 'hookbird' ), 'lte' => __( 'is at most', 'hookbird' ), 'in' => __( 'is one of (comma separated)', 'hookbird' ), 'not_in' => __( 'is not one of (comma separated)', 'hookbird' ), 'exists' => __( 'is not empty', 'hookbird' ), 'not_exists' => __( 'is empty', 'hookbird' ), ); } /** * Read a dot path from the event data. "order.total" reads $envelope['data']['order']['total']. * Paths starting with "event" or "site." read from the envelope itself. * * @param array $envelope Event envelope. * @param string $path Dot path. * @return mixed|null */ public static function resolve( array $envelope, $path ) { $path = (string) $path; if ( 'event' === $path ) { return isset( $envelope['event'] ) ? $envelope['event'] : null; } $value = 0 === strpos( $path, 'site.' ) ? $envelope : ( isset( $envelope['data'] ) ? $envelope['data'] : array() ); foreach ( explode( '.', $path ) as $segment ) { if ( is_array( $value ) && array_key_exists( $segment, $value ) ) { $value = $value[ $segment ]; } else { return null; } } return $value; } /** * Whether the envelope passes the endpoint's conditions. * * @param array $conditions { match: all|any, rules: array }. * @param array $envelope Event envelope. * @return bool */ public static function passes( array $conditions, array $envelope ) { $rules = isset( $conditions['rules'] ) ? (array) $conditions['rules'] : array(); if ( empty( $rules ) ) { return true; } $any = isset( $conditions['match'] ) && 'any' === $conditions['match']; // A rule only applies to events that carry its root object, so "order.total > 100" // filters orders without blocking post or user events on the same endpoint. $rules = array_filter( $rules, static function ( $rule ) use ( $envelope ) { return self::applies( (array) $rule, $envelope ); } ); if ( empty( $rules ) ) { return true; } foreach ( $rules as $rule ) { $result = self::evaluate( $rule, $envelope ); if ( $any && $result ) { return true; } if ( ! $any && ! $result ) { return false; } } return ! $any; } /** * Whether a rule is relevant to an event: its first path segment must exist in the data. * "event" and "site.*" rules always apply. * * @param array $rule Rule. * @param array $envelope Envelope. * @return bool */ public static function applies( array $rule, array $envelope ) { $field = isset( $rule['field'] ) ? (string) $rule['field'] : ''; if ( 'event' === $field || 0 === strpos( $field, 'site.' ) ) { return true; } $root = strtok( $field, '.' ); return isset( $envelope['data'] ) && is_array( $envelope['data'] ) && array_key_exists( (string) $root, $envelope['data'] ); } /** * Evaluate one rule. * * @param array $rule { field, op, value }. * @param array $envelope Envelope. * @return bool */ public static function evaluate( array $rule, array $envelope ) { $actual = self::resolve( $envelope, isset( $rule['field'] ) ? $rule['field'] : '' ); $expected = isset( $rule['value'] ) ? (string) $rule['value'] : ''; $op = isset( $rule['op'] ) ? $rule['op'] : 'equals'; switch ( $op ) { case 'exists': return ! self::is_empty( $actual ); case 'not_exists': return self::is_empty( $actual ); case 'equals': return self::equals( $actual, $expected ); case 'not_equals': return ! self::equals( $actual, $expected ); case 'contains': return self::contains( $actual, $expected ); case 'not_contains': return ! self::contains( $actual, $expected ); case 'in': return self::in_list( $actual, $expected ); case 'not_in': return ! self::in_list( $actual, $expected ); case 'gt': case 'gte': case 'lt': case 'lte': if ( ! is_numeric( $actual ) || ! is_numeric( $expected ) ) { return false; } $a = (float) $actual; $b = (float) $expected; if ( 'gt' === $op ) { return $a > $b; } if ( 'gte' === $op ) { return $a >= $b; } if ( 'lt' === $op ) { return $a < $b; } return $a <= $b; } return false; } /** * Empty check that treats "0" as a value. * * @param mixed $value Value. * @return bool */ private static function is_empty( $value ) { return null === $value || '' === $value || array() === $value || false === $value; } /** * Normalise a scalar for comparison. * * @param mixed $value Value. * @return string */ private static function norm( $value ) { if ( is_bool( $value ) ) { return $value ? 'true' : 'false'; } return is_scalar( $value ) ? strtolower( trim( (string) $value ) ) : ''; } /** * Case-insensitive equality; arrays match when any element equals. * * @param mixed $actual Actual. * @param string $expected Expected. * @return bool */ private static function equals( $actual, $expected ) { if ( is_array( $actual ) ) { foreach ( $actual as $item ) { if ( self::norm( $item ) === self::norm( $expected ) ) { return true; } } return false; } if ( is_numeric( $actual ) && is_numeric( $expected ) ) { return (float) $actual === (float) $expected; } return self::norm( $actual ) === self::norm( $expected ); } /** * Substring match for strings, membership for arrays. * * @param mixed $actual Actual. * @param string $expected Expected. * @return bool */ private static function contains( $actual, $expected ) { if ( is_array( $actual ) ) { return self::equals( $actual, $expected ); } $needle = self::norm( $expected ); return '' !== $needle && false !== strpos( self::norm( $actual ), $needle ); } /** * Whether the value is in a comma separated list. * * @param mixed $actual Actual. * @param string $expected Comma separated values. * @return bool */ private static function in_list( $actual, $expected ) { foreach ( explode( ',', $expected ) as $candidate ) { if ( '' !== trim( $candidate ) && self::equals( $actual, trim( $candidate ) ) ) { return true; } } return false; } }