platform_url = untrailingslashit( (string) $platform_url ); $this->api_key = (string) $api_key; } /** Builds a client from the plugin's stored settings - the constructor itself stays free of any get_option() call so it can be unit-tested with arbitrary values. */ public static function from_settings() { return new self( Settings::get_platform_url(), Settings::get_api_key() ); } /** * Local-development hosts are the one place a non-HTTPS platform URL * is tolerated (Part C) - localhost, 127.0.0.1/::1, or a *.test/*.local * TLD, matching the conventions most local WordPress + Node dev setups * already use. Never matches a real public hostname. */ public static function is_local_development_host( $host ) { $host = strtolower( (string) $host ); if ( in_array( $host, array( 'localhost', '127.0.0.1', '::1' ), true ) ) { return true; } return (bool) preg_match( '/\.(test|local)$/', $host ); } /** * GET /api/v1/forms - used both for Test Connection (Part D) and to * populate the destination-form dropdown (Part E). A single endpoint * serves both purposes deliberately: a successful call to it already * proves "platform reachable, key valid, forms:submit scope present" * without a separate, otherwise-unused connectivity check. */ public function list_forms() { return $this->request( 'GET', '/forms' ); } /* * Creating and editing forms (needs the FORMS_MANAGE permission, which * Connect gives a site since plugin 1.3.0) - /api/v1/manage/forms. * `$locale` is the WordPress admin's language, for ready-made forms' * names; what visitors read follows the workspace's subscriber language. */ public function manage_list_forms() { return $this->request( 'GET', '/manage/forms' ); } public function manage_list_starters( $locale ) { return $this->request( 'GET', '/manage/forms/starters?locale=' . rawurlencode( (string) $locale ) ); } /** @param array $body { starterId } or { name }. */ public function manage_create_form( array $body, $locale ) { return $this->request( 'POST', '/manage/forms?locale=' . rawurlencode( (string) $locale ), $body ); } public function manage_get_form( $public_id ) { return $this->request( 'GET', '/manage/forms/' . rawurlencode( $public_id ) ); } /** Only the fields given change. */ public function manage_update_form( $public_id, array $fields ) { return $this->request( 'PATCH', '/manage/forms/' . rawurlencode( $public_id ), $fields ); } public function manage_set_published( $public_id, $publish ) { return $this->request( 'POST', '/manage/forms/' . rawurlencode( $public_id ) . ( $publish ? '/publish' : '/unpublish' ), array() ); } /** The connected workspace's name and this key's permissions. */ public function get_meta() { return $this->request( 'GET', '/meta' ); } /** * POST /api/v1/forms/{publicId}/submissions - used when the visitor * gave marketing consent, so the submission routes through the * destination Form's own consent text/double-opt-in/group-tag * configuration on the 404 Emails side. */ public function submit_form( $public_id, array $body, $idempotency_key ) { return $this->request( 'POST', '/forms/' . rawurlencode( $public_id ) . '/submissions', $body, $idempotency_key ); } /** * POST /api/v1/contacts - used when the visitor did not give marketing * consent (or a provider has no destination Form context at all): v1 * Forms require consent to be given to accept a submission at all (no * contact-only mode - see lib/forms/submit.ts in the main repository), * so a non-consenting registration is routed here instead, with * marketingPermission=false, which the Contacts endpoint fully * supports as a first-class outcome (Contact created, no consent or * subscription fabricated). See Sync_Service::send() for the branch * that decides which endpoint to call. */ public function upsert_contact( array $body, $idempotency_key ) { return $this->request( 'POST', '/contacts', $body, $idempotency_key ); } /** * "Disconnect": revokes this site's own API key on 404 Emails, so it * stops working everywhere straight away. Best effort - the caller * forgets the key locally whatever the answer. */ public function revoke_connection() { return $this->request( 'POST', '/connection/revoke', array() ); } /** * The last step of "Connect to 404 Emails": swaps the one-time code from * the redirect, plus this site's secret PKCE verifier, for the site's own * API key. The only call made without a key (there isn't one yet). * * @param string $platform_url The 404 Emails app address. * @param string $code The one-time code from the redirect. * @param string $verifier The secret this site generated when the connection started. * @return Api_Response On success, data has apiKey, keyPrefix and workspaceName. */ public static function exchange_connect_code( $platform_url, $code, $verifier ) { $client = new self( $platform_url, '' ); return $client->request( 'POST', '/connect/wordpress/token', array( 'code' => (string) $code, 'codeVerifier' => (string) $verifier, ), null, false ); } private function request( $method, $path, array $body = null, $idempotency_key = null, $authenticated = true ) { if ( '' === $this->platform_url || ( $authenticated && '' === $this->api_key ) ) { return Api_Response::failure( null, 'not_configured', false ); } $url = $this->platform_url . self::API_VERSION_PATH . $path; $headers = array( 'Accept' => 'application/json', 'User-Agent' => self::USER_AGENT, ); if ( $authenticated ) { $headers['Authorization'] = 'Bearer ' . $this->api_key; } $args = array( 'method' => $method, 'timeout' => self::TIMEOUT_SECONDS, 'headers' => $headers, 'redirection' => 0, // A redirect would silently drop the Authorization header on some hosts - treat it as a failure instead of following it. ); if ( null !== $body ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( $body ); } if ( $idempotency_key ) { $args['headers']['Idempotency-Key'] = $idempotency_key; } // wp_remote_request() - the WordPress HTTP API, never a raw cURL // call - see Part C. Works identically for GET/POST via $args['method']. $response = wp_remote_request( $url, $args ); return $this->to_api_response( $response ); } private function to_api_response( $response ) { if ( is_wp_error( $response ) ) { $code = $response->get_error_code(); // WP_Http surfaces a timeout as an error whose code/message // contains "timed out" - everything else transport-level // (DNS failure, connection refused, TLS failure) is treated // as "unreachable." Both are retryable per Part M. if ( false !== strpos( strtolower( $response->get_error_message() ), 'timed out' ) ) { return Api_Response::failure( null, 'timeout', true ); } return Api_Response::failure( null, 'unreachable', true ); } $status = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status >= 200 && $status < 300 ) { return Api_Response::success( $status, is_array( $body ) ? $body : array() ); } return $this->failure_from_status( $status, is_array( $body ) ? $body : array() ); } private function failure_from_status( $status, array $body ) { $response = $this->classify_failure( $status, $body ); $response->data = $body; return $response; } private function classify_failure( $status, array $body ) { $code = isset( $body['error']['code'] ) ? (string) $body['error']['code'] : ''; switch ( $status ) { case 401: return Api_Response::failure( $status, 'unauthorized', false ); case 403: return Api_Response::failure( $status, 'forbidden_scope', false ); case 404: return Api_Response::failure( $status, 'not_found', false ); case 409: // IDEMPOTENCY_IN_PROGRESS is the one 409 the server itself // documents as "retry shortly" - IDEMPOTENCY_CONFLICT means // the same key was already used for a different payload, // which a retry can never fix. return Api_Response::failure( $status, 'idempotency_conflict', 'IDEMPOTENCY_IN_PROGRESS' === $code ); case 429: return Api_Response::failure( $status, 'rate_limited', true ); default: if ( $status >= 500 && $status < 600 ) { return Api_Response::failure( $status, 'server_error', true ); } if ( $status >= 400 && $status < 500 ) { return Api_Response::failure( $status, 'invalid_request', false ); } return Api_Response::failure( $status, 'unexpected', false ); } } }