bool, 'errors' => array] */ public function validate($payload) { $this->errors = []; // Validate required fields $this->validateRequiredFields($payload); if (!empty($this->errors)) { return ['valid' => false, 'errors' => $this->errors]; } // Validate data types and values $this->validateWorkoutType($payload['workout_type']); if (isset($payload['client_workout_id']) && (!is_string($payload['client_workout_id']) || preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $payload['client_workout_id']) !== 1)) { $this->errors[] = 'client_workout_id must be a UUID'; } $this->validateNumericField('distance_meters', $payload['distance_meters'], self::MIN_DISTANCE, self::MAX_DISTANCE); $this->validateNumericField('duration_seconds', $payload['duration_seconds'], self::MIN_DURATION, self::MAX_DURATION); $this->validateNumericField('elevation_gain_meters', $payload['elevation_gain_meters'], 0, 10000); $this->validateNumericField('elevation_loss_meters', $payload['elevation_loss_meters'], 0, 10000); $this->validateNumericField('calories_burned', $payload['calories_burned'], 0, 5000); $this->validateNumericField('max_speed_mps', $payload['max_speed_mps'], 0, 50); // Validate polyline $this->validatePolyline($payload['route_polyline']); // Validate timestamps $this->validateTimestamps($payload['start_time'], $payload['end_time']); // Validate optional fields if (isset($payload['weather_condition']) && !empty($payload['weather_condition'])) { $this->validateWeatherCondition($payload['weather_condition']); } if (isset($payload['temperature_celsius']) && $payload['temperature_celsius'] !== null) { if (!is_numeric($payload['temperature_celsius'])) { $this->errors[] = 'temperature_celsius must be numeric'; } else { $temp = floatval($payload['temperature_celsius']); if ($temp < -50 || $temp > 60) { $this->errors[] = 'temperature_celsius must be between -50 and 60 degrees'; } } } // Validate optional segments array if (isset($payload['segments']) && is_array($payload['segments'])) { $this->validateSegments($payload['segments']); } // Validate optional fields if (isset($payload['is_public']) && !is_bool($payload['is_public'])) { $this->errors[] = 'is_public must be a boolean'; } if (isset($payload['notes']) && !is_string($payload['notes'])) { $this->errors[] = 'notes must be a string'; } elseif (isset($payload['notes']) && strlen($payload['notes']) > 1000) { $this->errors[] = 'notes must not exceed 1000 characters'; } return [ 'valid' => empty($this->errors), 'errors' => $this->errors ]; } /** * Validate required fields exist */ private function validateRequiredFields($payload) { $required = [ 'workout_type', 'distance_meters', 'duration_seconds', 'route_polyline', 'start_time', 'end_time', 'elevation_gain_meters', 'elevation_loss_meters', 'calories_burned', 'max_speed_mps' ]; foreach ($required as $field) { if (!isset($payload[$field])) { $this->errors[] = "Missing required field: {$field}"; } } } /** * Validate workout type */ private function validateWorkoutType($type) { if (!in_array($type, self::VALID_WORKOUT_TYPES, true)) { $this->errors[] = 'Invalid workout_type. Must be: ' . implode(', ', self::VALID_WORKOUT_TYPES); } } /** * Validate numeric field within range */ private function validateNumericField($field_name, $value, $min, $max) { if (!is_numeric($value)) { $this->errors[] = "{$field_name} must be numeric"; return; } $numeric_value = floatval($value); if ($numeric_value < $min || $numeric_value > $max) { $this->errors[] = "{$field_name} must be between {$min} and {$max}"; } } /** * Validate polyline */ private function validatePolyline($polyline) { if (!is_string($polyline) || empty($polyline)) { $this->errors[] = 'route_polyline must be a non-empty string'; return; } // Validate format if (!PolylineUtility::validatePolyline($polyline)) { $this->errors[] = 'route_polyline has invalid format'; return; } // Decode and check coordinate count $coordinates = PolylineUtility::decodePolyline($polyline); if (count($coordinates) < self::MIN_COORDINATES) { $this->errors[] = 'route_polyline must contain at least ' . self::MIN_COORDINATES . ' coordinates'; } if (count($coordinates) > self::MAX_COORDINATES) { $this->errors[] = 'route_polyline must not exceed ' . self::MAX_COORDINATES . ' coordinates'; } // Validate coordinate format foreach ($coordinates as $coord) { if (!is_array($coord) || !isset($coord['lat']) || !isset($coord['lng'])) { $this->errors[] = 'Invalid coordinate format in polyline'; break; } if (!is_numeric($coord['lat']) || !is_numeric($coord['lng'])) { $this->errors[] = 'Coordinate values must be numeric'; break; } // Validate lat/lng ranges if ($coord['lat'] < -90 || $coord['lat'] > 90) { $this->errors[] = 'Latitude must be between -90 and 90'; break; } if ($coord['lng'] < -180 || $coord['lng'] > 180) { $this->errors[] = 'Longitude must be between -180 and 180'; break; } } } /** * Validate timestamps */ private function validateTimestamps($start_time, $end_time) { // Validate ISO 8601 format if (!$this->isValidISO8601($start_time)) { $this->errors[] = 'start_time must be in ISO 8601 format'; return; } if (!$this->isValidISO8601($end_time)) { $this->errors[] = 'end_time must be in ISO 8601 format'; return; } $start = strtotime($start_time); $end = strtotime($end_time); if ($start === false || $end === false) { $this->errors[] = 'Invalid timestamp format'; return; } if ($start >= $end) { $this->errors[] = 'start_time must be before end_time'; } // Validate timestamps are not in the future if ($end > time()) { $this->errors[] = 'end_time cannot be in the future'; } // Validate timestamps are recent (not more than 30 days old) if ($start < (time() - 30 * 24 * 60 * 60)) { $this->errors[] = 'Workout must be submitted within 30 days'; } } /** * Validate ISO 8601 datetime format */ private function isValidISO8601($date_string) { $pattern = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?([+-]\d{2}:\d{2}|Z)?$/'; return preg_match($pattern, $date_string) === 1; } /** * Validate weather condition */ private function validateWeatherCondition($condition) { $valid_conditions = ['sunny', 'cloudy', 'rainy', 'snowy', 'windy', 'foggy', 'hail']; if (!in_array(strtolower($condition), $valid_conditions, true)) { $this->errors[] = 'Invalid weather_condition. Must be one of: ' . implode(', ', $valid_conditions); } } /** * Validate segments array */ private function validateSegments($segments) { if (!is_array($segments) || empty($segments)) { $this->errors[] = 'segments must be a non-empty array'; return; } if (count($segments) > 1000) { $this->errors[] = 'segments array must not exceed 1000 items'; return; } foreach ($segments as $index => $segment) { if (!is_array($segment)) { $this->errors[] = "Segment {$index} must be an object"; continue; } if (!isset($segment['duration_seconds']) || !isset($segment['distance_meters'])) { $this->errors[] = "Segment {$index} must have duration_seconds and distance_meters"; } if (!is_numeric($segment['duration_seconds']) || intval($segment['duration_seconds']) < 0) { $this->errors[] = "Segment {$index} duration_seconds must be a positive integer"; } if (!is_numeric($segment['distance_meters']) || intval($segment['distance_meters']) < 0) { $this->errors[] = "Segment {$index} distance_meters must be a positive integer"; } } } } ?>