ga_is_api_request() ? 'en_US' : $locale, 1000 ); } /** Internal value => API value, per field. */ function ga_api_enums() { return array( 'estado' => array( 'disponible' => 'available', 'proceso' => 'pending', 'adoptado' => 'adopted' ), 'sexo' => array( 'macho' => 'male', 'hembra' => 'female' ), 'tamano' => array( 'pequeno' => 'small', 'mediano' => 'medium', 'grande' => 'large' ), 'edad_unidad' => array( 'anos' => 'years', 'meses' => 'months' ), ); } /** API property => internal field. Shared fields are common to every translation of the animal. */ function ga_api_map() { return array( 'status' => 'estado', 'featured' => 'destacado', 'sponsorable' => 'apadrinable', 'sex' => 'sexo', 'birthDate' => 'nacimiento', 'age' => 'edad', 'ageUnit' => 'edad_unidad', 'size' => 'tamano', 'arrivalDate' => 'llegada', 'galleryIds' => 'galeria', 'video' => 'video', 'personality' => 'caracter', 'needs' => 'necesidades', 'compatibility' => 'convivencia', 'idealHome' => 'hogar', ); } function ga_api_to( $field, $value ) { return ga_api_enums()[ $field ][ $value ] ?? ( '' === $value ? null : $value ); } function ga_api_from( $field, $value ) { $flip = array_flip( ga_api_enums()[ $field ] ?? array() ); return $flip[ $value ] ?? null; } /** Animal profile for the APIs (English keys and values, plus readable labels in the animal's language). */ function ga_animal_api_data( $post_id ) { $lang = function_exists( 'pll_get_post_language' ) ? ( pll_get_post_language( $post_id ) ?: 'es' ) : 'es'; $gallery = array_values( array_filter( array_map( 'absint', explode( ',', ga_get( $post_id, 'galeria' ) ) ) ) ); $text = fn( $key ) => '' === ga_get( $post_id, $key ) ? null : ga_get( $post_id, $key ); $data = array( 'status' => ga_api_to( 'estado', ga_status( $post_id ) ), 'featured' => (bool) ga_get( $post_id, 'destacado' ), 'sponsorable' => (bool) ga_get( $post_id, 'apadrinable' ), 'sex' => ga_api_to( 'sexo', ga_get( $post_id, 'sexo' ) ), 'birthDate' => $text( 'nacimiento' ), 'age' => '' === ga_get( $post_id, 'edad' ) ? null : (float) ga_get( $post_id, 'edad' ), 'ageUnit' => ga_api_to( 'edad_unidad', ga_get( $post_id, 'edad_unidad' ) ), 'size' => ga_api_to( 'tamano', ga_get( $post_id, 'tamano' ) ), 'arrivalDate' => $text( 'llegada' ), 'galleryIds' => $gallery, 'photos' => array_values( array_filter( array_map( fn( $id ) => wp_get_attachment_image_url( $id, 'large' ), ga_gallery_ids( $post_id ) ) ) ), 'video' => $text( 'video' ), 'personality' => $text( 'caracter' ), 'needs' => $text( 'necesidades' ), 'compatibility' => $text( 'convivencia' ), 'idealHome' => $text( 'hogar' ), 'language' => $lang, ); $data['labels'] = ga_with_lang( $lang, fn() => array( 'status' => ga_label( $post_id, 'estado' ), 'sex' => ga_label( $post_id, 'sexo' ) ?: null, 'age' => ga_age( $post_id ) ?: null, 'size' => ga_label( $post_id, 'tamano' ) ?: null, ) ); return $data; } /** Writes an API profile into the animal. Shared fields are copied to its translations. */ function ga_animal_api_update( array $profile, $post_id ) { $fields = ga_fields(); foreach ( ga_api_map() as $prop => $key ) { if ( ! array_key_exists( $prop, $profile ) ) { continue; } $raw = $profile[ $prop ]; switch ( $prop ) { case 'status': case 'sex': case 'size': case 'ageUnit': $value = null === $raw ? '' : ga_api_from( $key, $raw ); if ( null === $value ) { return new WP_Error( 'rest_invalid_param', "Invalid value for profile.$prop.", array( 'status' => 400 ) ); } break; case 'featured': case 'sponsorable': $value = $raw ? '1' : ''; break; case 'age': $value = null === $raw ? '' : (string) absint( $raw ); break; case 'birthDate': case 'arrivalDate': $value = (string) $raw; if ( '' !== $value && ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) { return new WP_Error( 'rest_invalid_param', "profile.$prop must be YYYY-MM-DD.", array( 'status' => 400 ) ); } break; case 'galleryIds': $value = implode( ',', array_filter( array_map( 'absint', (array) $raw ) ) ); break; case 'video': $value = esc_url_raw( (string) $raw ); break; default: $value = sanitize_textarea_field( (string) $raw ); } if ( ! empty( $fields[ $key ]['shared'] ) ) { ga_update_meta_all_languages( $post_id, "_ga_$key", $value ); } else { update_post_meta( $post_id, "_ga_$key", $value ); } } return true; } function ga_lines_of( $text ) { return array_values( array_filter( array_map( 'trim', explode( "\n", (string) $text ) ) ) ); } /** Default needs list (same as the theme registry), used when nothing has been saved yet. */ function ga_default_needs( $lang ) { if ( function_exists( 'ga_customizer_registry' ) ) { return ga_customizer_registry()['material']['fields']['list'][ $lang ] ?? ''; } return ''; } /** JSON schema of the profile (used by REST validation and the OpenAPI docs). */ function ga_animal_api_schema() { $string_or_null = array( 'type' => array( 'string', 'null' ) ); $enum = fn( $field ) => array( 'type' => array( 'string', 'null' ), 'enum' => array_merge( array_values( ga_api_enums()[ $field ] ), array( null ) ) ); return array( 'description' => 'Adoption profile of the animal. Shared fields are common to all translations; texts are per language.', 'type' => 'object', 'context' => array( 'view', 'edit' ), 'properties' => array( 'status' => array( 'type' => 'string', 'enum' => array_values( ga_api_enums()['estado'] ), 'description' => 'Adopted animals are hidden from public listings.' ), 'featured' => array( 'type' => 'boolean', 'description' => 'Shown on the home page.' ), 'sponsorable' => array( 'type' => 'boolean', 'description' => 'Looking for a sponsor.' ), 'sex' => $enum( 'sexo' ), 'birthDate' => $string_or_null + array( 'format' => 'date' ), 'age' => array( 'type' => array( 'number', 'null' ), 'description' => 'Approximate age, used when birthDate is unknown.' ), 'ageUnit' => $enum( 'edad_unidad' ), 'size' => $enum( 'tamano' ), 'arrivalDate' => $string_or_null + array( 'format' => 'date', 'description' => 'Date the animal arrived at the shelter.' ), 'galleryIds' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ), 'description' => 'Attachment IDs of the extra photos (the featured image is the main photo).' ), 'photos' => array( 'type' => 'array', 'items' => array( 'type' => 'string', 'format' => 'uri' ), 'readonly' => true, 'description' => 'Main photo followed by the gallery.' ), 'video' => $string_or_null + array( 'format' => 'uri' ), 'personality' => $string_or_null, 'needs' => $string_or_null, 'compatibility' => $string_or_null + array( 'description' => 'Observed behaviour with children and other animals, in context.' ), 'idealHome' => $string_or_null, 'language' => array( 'type' => 'string', 'enum' => array( 'es', 'eu' ), 'readonly' => true ), 'labels' => array( 'type' => 'object', 'readonly' => true, 'description' => 'Human-readable values in the animal\'s language.' ), ), ); } add_action( 'rest_api_init', function () { // Language of the animal and link to its translation (write), e.g. {"language":"eu","translation_of":123}. register_rest_field( 'animal', 'language', array( 'get_callback' => fn( $post ) => function_exists( 'pll_get_post_language' ) ? pll_get_post_language( $post['id'] ) : 'es', 'update_callback' => function ( $value, $post ) { if ( function_exists( 'pll_set_post_language' ) ) { pll_set_post_language( $post->ID, $value ); } return true; }, 'schema' => array( 'type' => 'string', 'enum' => array( 'es', 'eu' ), 'description' => 'Language of this translation.', 'context' => array( 'view', 'edit' ) ), ) ); register_rest_field( 'animal', 'translation_of', array( 'get_callback' => function ( $post ) { $translations = function_exists( 'pll_get_post_translations' ) ? pll_get_post_translations( $post['id'] ) : array(); unset( $translations[ pll_get_post_language( $post['id'] ) ?: '' ] ); return $translations ? (int) reset( $translations ) : null; }, 'update_callback' => function ( $value, $post ) { if ( ! $value || ! function_exists( 'pll_save_post_translations' ) || 'animal' !== get_post_type( $value ) ) { return true; } if ( ! current_user_can( 'edit_post', $value ) ) { return new WP_Error( 'rest_forbidden', 'Cannot edit the linked animal.', array( 'status' => 403 ) ); } $translations = pll_get_post_translations( $value ); $translations[ pll_get_post_language( $post->ID ) ] = $post->ID; pll_save_post_translations( $translations ); // The new translation inherits the shared data (status, dates, photos…). foreach ( ga_fields() as $key => $field ) { if ( ! empty( $field['shared'] ) && '' === ga_get( $post->ID, $key ) && '' !== ga_get( $value, $key ) ) { update_post_meta( $post->ID, "_ga_$key", ga_get( $value, $key ) ); } } if ( ! has_post_thumbnail( $post->ID ) && has_post_thumbnail( $value ) ) { set_post_thumbnail( $post->ID, get_post_thumbnail_id( $value ) ); } return true; }, 'schema' => array( 'type' => array( 'integer', 'null' ), 'description' => 'ID of the same animal in the other language.', 'context' => array( 'view', 'edit' ) ), ) ); // Material needs list (Personalizar → Ayuda material), per language. register_rest_route( 'bilagun/v1', '/needs', array( array( 'methods' => 'GET', 'permission_callback' => '__return_true', 'callback' => fn() => array( 'es' => ga_lines_of( get_theme_mod( 'ga_material_list', ga_default_needs( 'es' ) ) ), 'eu' => ga_lines_of( get_theme_mod( 'ga_material_list_eu', ga_default_needs( 'eu' ) ) ), ), ), array( 'methods' => 'PUT', 'permission_callback' => fn() => current_user_can( 'edit_others_posts' ), 'args' => array( 'es' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ), 'eu' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ), ), 'callback' => function ( WP_REST_Request $req ) { foreach ( array( 'es' => 'ga_material_list', 'eu' => 'ga_material_list_eu' ) as $lang => $mod ) { if ( null !== $req->get_param( $lang ) ) { $items = array_filter( array_map( 'sanitize_text_field', (array) $req->get_param( $lang ) ) ); set_theme_mod( $mod, implode( "\n", $items ) ); } } return array( 'es' => ga_lines_of( get_theme_mod( 'ga_material_list', '' ) ), 'eu' => ga_lines_of( get_theme_mod( 'ga_material_list_eu', '' ) ), ); }, ), ) ); register_rest_field( 'animal', 'profile', array( 'get_callback' => fn( $post ) => ga_animal_api_data( $post['id'] ), 'update_callback' => fn( $value, $post ) => ga_animal_api_update( (array) $value, $post->ID ), 'schema' => ga_animal_api_schema(), ) ); } ); add_action( 'graphql_register_types', function () { foreach ( array( 'AnimalStatusEnum' => array( 'estado', 'Adoption status' ), 'AnimalSexEnum' => array( 'sexo', 'Sex' ), 'AnimalSizeEnum' => array( 'tamano', 'Size (dogs)' ), 'AnimalAgeUnitEnum' => array( 'edad_unidad', 'Unit of the approximate age' ), ) as $type => [ $field, $description ] ) { $values = array(); foreach ( ga_api_enums()[ $field ] as $api ) { $values[ strtoupper( $api ) ] = array( 'value' => $api ); } register_graphql_enum_type( $type, array( 'description' => $description, 'values' => $values ) ); } register_graphql_object_type( 'AnimalProfileLabels', array( 'description' => 'Human-readable values in the animal\'s language.', 'fields' => array( 'status' => array( 'type' => 'String' ), 'sex' => array( 'type' => 'String' ), 'age' => array( 'type' => 'String' ), 'size' => array( 'type' => 'String' ), ), ) ); register_graphql_object_type( 'AnimalProfile', array( 'description' => 'Adoption profile of the animal.', 'fields' => array( 'status' => array( 'type' => 'AnimalStatusEnum' ), 'featured' => array( 'type' => 'Boolean' ), 'sponsorable' => array( 'type' => 'Boolean' ), 'sex' => array( 'type' => 'AnimalSexEnum' ), 'birthDate' => array( 'type' => 'String' ), 'age' => array( 'type' => 'Float' ), 'ageUnit' => array( 'type' => 'AnimalAgeUnitEnum' ), 'size' => array( 'type' => 'AnimalSizeEnum' ), 'arrivalDate' => array( 'type' => 'String' ), 'photos' => array( 'type' => array( 'list_of' => 'String' ) ), 'video' => array( 'type' => 'String' ), 'personality' => array( 'type' => 'String' ), 'needs' => array( 'type' => 'String' ), 'compatibility' => array( 'type' => 'String' ), 'idealHome' => array( 'type' => 'String' ), 'language' => array( 'type' => 'String' ), 'labels' => array( 'type' => 'AnimalProfileLabels' ), ), ) ); register_graphql_field( 'Animal', 'profile', array( 'type' => 'AnimalProfile', 'resolve' => fn( $post ) => ga_animal_api_data( $post->databaseId ), ) ); register_graphql_field( 'Animal', 'language', array( 'type' => 'String', 'description' => 'Language of this translation (es or eu).', 'resolve' => fn( $post ) => function_exists( 'pll_get_post_language' ) ? pll_get_post_language( $post->databaseId ) : 'es', ) ); register_graphql_field( 'RootQueryToAnimalConnectionWhereArgs', 'language', array( 'type' => 'String', 'description' => 'Only animals in this language (es or eu).', ) ); register_graphql_field( 'RootQueryToAnimalConnectionWhereArgs', 'adoptionStatus', array( 'type' => 'AnimalStatusEnum', 'description' => 'Only animals with this adoption status.', ) ); } ); /* REST: «?lang=es|eu» en /wp/v2/animals (Polylang gratuito no filtra la API REST por idioma). */ add_filter( 'rest_animal_collection_params', function ( $params ) { $params['lang'] = array( 'type' => 'string', 'enum' => array( 'es', 'eu' ), 'description' => 'Only animals in this language.' ); return $params; } ); add_filter( 'rest_animal_query', function ( $args, $request ) { $args['lang'] = $request['lang'] ?: ''; return $args; }, 10, 2 ); /* GraphQL: «where: { language, adoptionStatus }» en la consulta de animales («status» ya es el estado de publicación de WordPress). */ add_filter( 'graphql_post_object_connection_query_args', function ( $args, $source, $input ) { if ( 'animal' !== ( $args['post_type'] ?? '' ) && array( 'animal' ) !== ( $args['post_type'] ?? '' ) ) { return $args; } $where = $input['where'] ?? array(); $args['lang'] = in_array( $where['language'] ?? '', array( 'es', 'eu' ), true ) ? $where['language'] : ''; if ( ! empty( $where['adoptionStatus'] ) ) { $internal = ga_api_from( 'estado', $where['adoptionStatus'] ); $values = 'proceso' === $internal ? array( 'proceso', 'reservado' ) : array( $internal ); $args['meta_query'][] = 'disponible' === $internal ? array( 'relation' => 'OR', array( 'key' => '_ga_estado', 'value' => array( 'disponible', 'urgente', 'acogida' ), 'compare' => 'IN' ), array( 'key' => '_ga_estado', 'compare' => 'NOT EXISTS' ) ) : array( 'key' => '_ga_estado', 'value' => $values, 'compare' => 'IN' ); } return $args; }, 10, 3 ); /** Converts a WordPress JSON schema into an OpenAPI 3.0 schema (nullable types, readOnly). */ function ga_openapi_schema( $schema ) { if ( isset( $schema['type'] ) && is_array( $schema['type'] ) ) { $types = array_values( array_diff( $schema['type'], array( 'null' ) ) ); if ( count( $types ) < count( $schema['type'] ) ) { $schema['nullable'] = true; } $schema['type'] = $types[0] ?? 'string'; } if ( isset( $schema['enum'] ) ) { $schema['enum'] = array_values( array_filter( $schema['enum'], fn( $v ) => null !== $v ) ); } if ( ! empty( $schema['readonly'] ) ) { $schema['readOnly'] = true; } unset( $schema['readonly'], $schema['context'] ); foreach ( array( 'properties' ) as $key ) { if ( isset( $schema[ $key ] ) ) { $schema[ $key ] = array_map( 'ga_openapi_schema', $schema[ $key ] ); } } if ( isset( $schema['items'] ) ) { $schema['items'] = ga_openapi_schema( $schema['items'] ); } return $schema; } /* * OpenAPI schema (/rest-api/schema): the plugin flattens object fields to "string". * We post-process its output to document «profile» properly (component AnimalProfile). */ add_action( 'wp', function () { if ( 'schema' !== get_query_var( 'swagger_api' ) ) { return; } ob_start( function ( $json ) { $doc = json_decode( $json, true ); if ( ! is_array( $doc ) || empty( $doc['paths'] ) ) { return $json; } $profile = ga_openapi_schema( ga_animal_api_schema() ); $ref = array( '$ref' => '#/components/schemas/AnimalProfile' ); $doc['components']['schemas']['AnimalProfile'] = $profile; $doc['components']['schemas']['Animal'] = array( 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer' ), 'slug' => array( 'type' => 'string' ), 'link' => array( 'type' => 'string', 'format' => 'uri' ), 'title' => array( 'type' => 'object', 'properties' => array( 'rendered' => array( 'type' => 'string' ) ) ), 'content' => array( 'type' => 'object', 'properties' => array( 'rendered' => array( 'type' => 'string' ) ) ), 'species' => array( 'type' => 'array', 'items' => array( 'type' => 'integer' ) ), 'lang' => array( 'type' => 'string', 'description' => 'Language of this translation (es or eu).' ), 'profile' => $ref, ), ); foreach ( $doc['paths'] as $path => &$methods ) { if ( ! preg_match( '#^/wp/v2/animals(/\{id\})?$#', $path, $m ) ) { continue; } foreach ( $methods as $method => &$op ) { foreach ( $op['requestBody']['content'] ?? array() as $type => $content ) { if ( isset( $content['schema']['properties']['profile'] ) ) { $op['requestBody']['content'][ $type ]['schema']['properties']['profile'] = $ref; } } // JSON first: «profile» is a nested object, which form-encoded bodies can't express well. if ( ! empty( $op['requestBody']['content'] ) && empty( $op['requestBody']['content']['application/json'] ) ) { $first = reset( $op['requestBody']['content'] ); $op['requestBody']['content'] = array( 'application/json' => $first ) + $op['requestBody']['content']; } if ( 'get' === $method ) { $item = array( '$ref' => '#/components/schemas/Animal' ); $op['responses']['200'] = array( 'description' => 'OK', 'content' => array( 'application/json' => array( 'schema' => empty( $m[1] ) ? array( 'type' => 'array', 'items' => $item ) : $item ) ), ); } } unset( $op ); } unset( $methods ); return wp_json_encode( $doc ); } ); }, 0 ); // Antes que el plugin, que emite el esquema en «wp». /* * Documentación Swagger (/rest-api/docs/). * * El plugin WP API SwaggerUI genera bien el esquema OpenAPI (/rest-api/schema), pero su paquete * en wordpress.org no incluye los archivos de la interfaz (assets/), así que su página sale en blanco. * Servimos nuestra propia página con Swagger UI oficial (versión fijada y con integridad SRI). * Solo para usuarios conectados, salvo API_DOCS_PUBLIC=true. */ define( 'GA_SWAGGER_UI_VERSION', '5.33.0' ); add_action( 'template_redirect', function () { if ( 'docs' !== get_query_var( 'swagger_api' ) ) { return; } if ( 'true' !== getenv( 'API_DOCS_PUBLIC' ) && ! is_user_logged_in() ) { auth_redirect(); } $cdn = 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@' . GA_SWAGGER_UI_VERSION; $config = array( 'url' => home_url( '/rest-api/schema' ), 'nonce' => is_user_logged_in() ? wp_create_nonce( 'wp_rest' ) : '', ); nocache_headers(); header( 'Content-Type: text/html; charset=utf-8' ); ?>