Una vez finalizadas las vacaciones de verano, retomo este tema y espero que os guste.
Para la elaboración de la codificación me he ayudado de las IA’s, en concreto Copilot. En el caso concreto de Svelte 5 y de los componentes de SVAR, ninguna de las que he probado conocen bien la plataforma (ChatGPT, Gemini, Deep Seek), siempre con versiones de no pago. Esto me hace pensar que los productos del último año, y más, los de los últimos meses, no están en sus bases de datos y aunque les facilites URL’s para que se ilustren, no me ha dado buen resultado.
Los componentes de SVAR, cuando entiendes cómo funcionan, el resultado es bueno y todo empieza a clarificarse y a verse «relativamente» sencillo.
Objetivo
Mostrar un CRUD de un tabla con campos de texto, numérico, fecha , combos t especial (estrellas).
Demo: https://fhumanes.com/my-svar-app/
Es la misma base de datos del ejemplo este ejemplo
Solución Técnica
Lo primero que deseo explicaros es la parte del Server, pues estoy muy contento con el resultado, creo que es muy sencillo programar esta parte (SLIM 4.X y PHP) y el resultado es muy bueno.
Mi entorno de desarrollo es NetBeans for PHP (para la programación del Server) y MS Visual Studio Code (para los desarrollos del front-end).
La estructura de la parte del server (movie-server), consta de 3 directorios:
- «include«.- Dónde situamos el fichero de configuración «Config.php», el fichero de funciones generales que no acceden a base de datos «Function.php» y el fichero que tiene la clase de todas las funciones de la aplicación y que tienen relación con el acceso a los datos de la base de datos y las respuestas de todas las peticiones «DbMovies.php».
- «libs«.- que es el micro-framework de SLIM con las extensiones que necesitemos.
- «v1«.- Que mantiene el fichero de entrada y validación de las peticiones que se pueden hacer «index.php». También, mantiene el fichero «.htaccess» que será igual en todos los casos.
El contenido de estos ficheros es:
<?php // URL de ejecución de la aplicación define('SCRIPTS_DIR', '/movie-server/v1'); $server = 'test'; // 'test' or 'production' // Configuración del entorno de Desarrollo "test" o de Producción if ($server == 'test' ) { // Path root of Directory files in File System or Disc define('DB_HOST', 'localhost'); define('DB_USER', 'root'); define('DB_PASSWORD', 'humanes'); define('DB_NAME', 'svelte-app1'); } else { // In Server Linux define('DB_HOST', 'localhost'); define('DB_USER', 'u637977917_factu'); define('DB_PASSWORD', 'humanes'); define('DB_NAME', 'linux-svelte-app1'); } //referencia generado con MD5(uniqueid(<some_string>, true)) define('API_KEY','3d524a53c110e4c22463b10ed32cef9d'); /** * API Response HTTP CODE * Used as reference for API REST Response Header * */ /* 200 OK 201 Created 304 Not Modified 400 Bad Request 401 Unauthorized 403 Forbidden 404 Not Found 409 Conflict 422 Unprocessable Entity 500 Internal Server Error */ // Error messages to facilitate their translation $errorMessages = array( "001" => "Authorization Token Missing", "002" => "The authorization token is incorrect", "003" => "Required field(s) or attribute(s) {1} is missing or empty", "004" => "Required Action (add,view,update,delete)", "005" => "Película no encontrada", "006" => "Película creada", "007" => "Película no encontrada o sin cambios", "008" => "Película actualizada", "009" => "Película no encontrada", "010" => "Película eliminada", "011" => "El nombre del tema es obligatorio", "012" => "Tema creado con éxito", "013" => "El tema ya existe", "014" => "Error interno del servidor al crear el tema", "015" => "Tema no encontrado o sin cambios", "016" => "Tema actualizado con éxito", "017" => "Tema no encontrado", "018" => "Tema eliminado con éxito", "019" => "No se puede eliminar el tema porque está asignado a una o más películas", "020" => "La Película ya existe", "021" => "", "022" => "", "023" => "", "024" => "", "025" => "", "026" => "" )
<?php /*********************** USEFULL FUNCTIONS **************************************/ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Selective\BasePath\BasePathMiddleware; use Slim\Factory\AppFactory; /** * Verificando los parametros requeridos en el método */ function verifyRequiredParams($required_fields, $request_params // , Request $request, Response $response ) { global $errorMessages; $error = false; $error_fields = ""; // $request_params = $request->getParsedBody(); foreach ($required_fields as $field) { // if (!isset($request_params[$field]) || strlen(trim($request_params[$field])) <= 0) { if (!isset($request_params[$field])) { $error = true; $error_fields .= $field . ', '; } } if ($error) { // Required field(s) are missing or empty // echo error json and stop the app $responseBody = array(); // $responseBody["error"] = true; // $responseBody["message_num"] = '003'; $responseBody["message"] = str_replace("{1}",substr($error_fields, 0, -2),$errorMessages['003']); return $responseBody; } return true; } /** * Revisa si la consulta contiene un Header "Authorization" para validar */ function authenticate(Request $request, Response $response) { global $errorMessages; // Getting request headers $headers = $request->getHeaders(); // Verifying Authorization || authorization Header if (isset($headers['Authorization'])|| isset($headers['authorization']) ) { // get the api key if (isset($headers['Authorization']) ) $token = $headers['Authorization']; if (isset($headers['authorization']) ) $token = $headers['authorization']; // validating api key if (!($token[0] == API_KEY)) { //API_KEY declarada en Config.php // api key is not present in users table // $responseBody["error"] = true; //$responseBody["message_num"] = '002'; $responseBody["message"] = $errorMessages['002']; // Error 401 return $responseBody; } else { //procede utilizar el recurso o metodo del llamado return true; } } else { // api key is missing in header // $responseBody["error"] = true; // $responseBody["message_num"] = '001'; $responseBody["message"] = $errorMessages['001']; // error 400 return $responseBody; } }
<?php include_once __DIR__.'/Config.php'; // Configuration Rest Api include_once __DIR__.'/Function.php'; // General Function /** * * @About: Gestión de Películas * @File: DbMovies.php * @Date: $Date:$ jul 2025 * @Version: $Rev:$ 1.0 * @Developer: fernando humanes **/ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; class DbMovies { private $db; function __construct() { $dbHost = DB_HOST; $dbName = DB_NAME; $dsn = "mysql:host=$dbHost;dbname=$dbName;charset=utf8mb4"; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; try { $pdo = new PDO($dsn, DB_USER, DB_PASSWORD, $options); $this->db = $pdo; } catch (PDOException $e) { throw new PDOException($e->getMessage(), (int)$e->getCode()); } } // --------------------------------------------------------------------------- // --- Obtener todas las películas --- public function getAllMovies(Request $request, Response $response, array $args): Response { /* Verify Token Authenticate Security $verify = authenticate($request, $response); if (is_array($verify)) { // Si es una array, es que hay error $response->getBody()->write(json_encode($verify)); return $response ->withHeader('content-type', 'application/json') ->withStatus(404); } */ $stmt = $this->db->query(" SELECT m.id_movie, m.name, m.price, m.startDate, m.rating, t.theme, m.theme_id, s.support, m.support_id FROM movie m JOIN theme t ON m.theme_id = t.id_theme JOIN support s ON m.support_id = s.id_support ORDER BY m.id_movie ASC "); $movies = $stmt->fetchAll(PDO::FETCH_ASSOC); $response->getBody()->write(json_encode($movies, JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(200); } // --- Obtener una película por ID --- public function getMovieById(Request $request, Response $response, array $args): Response { global $errorMessages; $id = $args['id']; $stmt = $this->db->prepare(" SELECT m.id_movie, m.name, m.price, m.startDate, m.rating, t.theme, m.theme_id, s.support, m.support_id FROM movie m JOIN theme t ON m.theme_id = t.id_theme JOIN support s ON m.support_id = s.id_support WHERE m.id_movie = :id "); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); $movie = $stmt->fetch(PDO::FETCH_ASSOC); if (!$movie) { $response->getBody()->write(json_encode(['message' => $errorMessages['005']], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(404); } $response->getBody()->write(json_encode($movie, JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(200); } // --- Crear una nueva película --- public function createMovie(Request $request, Response $response, array $args): Response { global $errorMessages; $data = $request->getParsedBody(); // Validar datos de entrada (simple) $verify = verifyRequiredParams(array('name','price','startDate','rating','theme_id','support_id'), $data); if (is_array($verify)) { // Si es una array, es que hay error $response->getBody()->write(json_encode($verify), JSON_UNESCAPED_UNICODE); return $response ->withHeader('content-type', 'application/json') ->withStatus(400); } try { $stmt = $this->db->prepare(" INSERT INTO movie (name, price, startDate, rating, theme_id, support_id) VALUES (:name, :price, :startDate, :rating, :theme_id, :support_id) "); $stmt->bindParam(':name', $data['name']); $stmt->bindParam(':price', $data['price']); $stmt->bindParam(':startDate', $data['startDate']); $stmt->bindParam(':rating', $data['rating'], PDO::PARAM_INT); $stmt->bindParam(':theme_id', $data['theme_id'], PDO::PARAM_INT); $stmt->bindParam(':support_id', $data['support_id'], PDO::PARAM_INT); $stmt->execute(); $newId = $this->db->lastInsertId(); // Obtén la película completa recién creada si es necesario, o simplemente devuelve el nombre. // Para simplificar, devolvemos el nombre que se envió, y el nuevo ID. $response->getBody()->write(json_encode([ 'message' => $errorMessages['006'], 'id' => $newId, 'name' => $data['name'] ], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(201); } catch (PDOException $e) { // Error La Película ya existe (ej. columna 'tname' es UNIQUE) if ($e->getCode() === '23000') { // Código de error SQL para violación de unicidad $response->getBody()->write(json_encode(['message' => $errorMessages['020']], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(409); // 409 Conflict } $response->getBody()->write(json_encode(['message' => $errorMessages['014'], 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(500); } } // --- Actualizar una película existente --- public function updateMovie(Request $request, Response $response, array $args): Response { $id = $args['id']; global $errorMessages; $data = $request->getParsedBody(); // Validar datos de entrada (simple) $verify = verifyRequiredParams(array('name','price','startDate','rating','theme_id','support_id'), $data); if (is_array($verify)) { // Si es una array, es que hay error $response->getBody()->write(json_encode($verify), JSON_UNESCAPED_UNICODE); return $response ->withHeader('content-type', 'application/json') ->withStatus(400); } $stmt = $this->db->prepare(" UPDATE movie SET name = :name, price = :price, startDate = :startDate, rating = :rating, theme_id = :theme_id, support_id = :support_id WHERE id_movie = :id "); $stmt->bindParam(':name', $data['name']); $stmt->bindParam(':price', $data['price']); $stmt->bindParam(':startDate', $data['startDate']); $stmt->bindParam(':rating', $data['rating'], PDO::PARAM_INT); $stmt->bindParam(':theme_id', $data['theme_id'], PDO::PARAM_INT); $stmt->bindParam(':support_id', $data['support_id'], PDO::PARAM_INT); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); if ($stmt->rowCount() === 0) { $response->getBody()->write(json_encode(['message' => $errorMessages['007']], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(404); } $response->getBody()->write(json_encode([ 'message' => $errorMessages['008'], 'name' => $data['name'] ], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(200); } // --- Eliminar una película --- public function deleteMovie(Request $request, Response $response, array $args): Response { $id = $args['id']; global $errorMessages; $stmt = $this->db->prepare("DELETE FROM movie WHERE id_movie = :id"); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); if ($stmt->rowCount() === 0) { $response->getBody()->write(json_encode(['message' => $errorMessages['009']], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(404); } $response->getBody()->write(json_encode(['message' => $errorMessages['010']], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(200); } // --- CRUD para Temas --- // --- Obtener todas las películas --- public function getAllThemes(Request $request, Response $response, array $args): Response { $stmt = $this->db->query("SELECT id_theme, theme FROM theme ORDER BY theme"); $themes = $stmt->fetchAll(PDO::FETCH_ASSOC); $response->getBody()->write(json_encode($themes, JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(200); } public function createTheme(Request $request, Response $response, array $args): Response { global $errorMessages; $data = $request->getParsedBody(); // Validar datos de entrada (simple) $verify = verifyRequiredParams(array('theme'), $data); if (is_array($verify)) { // Si es una array, es que hay error // $response->getBody()->write(json_encode($verify), JSON_UNESCAPED_UNICODE); $response->getBody()->write(json_encode(['message' => $errorMessages['011']], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('content-type', 'application/json') ->withStatus(400); } try { $stmt = $this->db->prepare("INSERT INTO theme (theme) VALUES (:theme)"); $stmt->bindParam(':theme', $data['theme']); $stmt->execute(); $newId = $this->db->lastInsertId(); $response->getBody()->write(json_encode([ 'message' => $errorMessages['012'], 'id' => $newId, 'theme' => $data['theme'] ], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(201); } catch (PDOException $e) { // Error si el tema ya existe (ej. columna 'theme' es UNIQUE) if ($e->getCode() === '23000') { // Código de error SQL para violación de unicidad $response->getBody()->write(json_encode(['message' => $errorMessages['013']], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(409); // 409 Conflict } $response->getBody()->write(json_encode(['message' => $errorMessages['014'], 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(500); } } public function updateTheme(Request $request, Response $response, array $args): Response { $id = $args['id']; global $errorMessages; $data = $request->getParsedBody(); // Validar datos de entrada (simple) $verify = verifyRequiredParams(array('theme'), $data); if (is_array($verify)) { // Si es una array, es que hay error // $response->getBody()->write(json_encode($verify), JSON_UNESCAPED_UNICODE); $response->getBody()->write(json_encode(['message' => $errorMessages['011']], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('content-type', 'application/json') ->withStatus(400); } try { $stmt = $this->db->prepare("UPDATE theme SET theme = :theme WHERE id_theme = :id"); $stmt->bindParam(':theme', $data['theme']); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); if ($stmt->rowCount() === 0) { $response->getBody()->write(json_encode(['message' => $errorMessages['015']], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(404); } $response->getBody()->write(json_encode([ 'message' => $errorMessages['016'], 'id' => $id, 'theme' => $data['theme'] ], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(200); } catch (PDOException $e) { if ($e->getCode() === '23000') { // Código de error SQL para violación de unicidad $response->getBody()->write(json_encode(['message' => $errorMessages['013']], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(409); } $response->getBody()->write(json_encode(['message' => $errorMessages['014'], 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(500); } } public function deleteTheme(Request $request, Response $response, array $args): Response { $id = $args['id']; // --- Manejo de Integridad Referencial (FASE POSTERIOR, AHORA SOLO ELIMINA) --- // Para la fase 5, aquí iría la verificación de si hay películas usando este tema. // Por ahora, simplemente intentamos eliminar. Si hay una restricción FOREIGN KEY, // la base de datos lanzará una excepción PDO. global $errorMessages; try { $stmt = $this->db->prepare("DELETE FROM theme WHERE id_theme = :id"); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); if ($stmt->rowCount() === 0) { $response->getBody()->write(json_encode(['message' => $errorMessages['017']], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(404); } $response->getBody()->write(json_encode(['message' => $errorMessages['018']], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(200); } catch (PDOException $e) { // Código de error 23000 es para violación de integridad (Foreign Key Constraint) if ($e->getCode() === '23000') { $response->getBody()->write(json_encode(['message' => $errorMessages['019']], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(409); // 409 Conflict } $response->getBody()->write(json_encode(['message' => $errorMessages['014'], 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(500); } } // --- CRUD para Soportes --- // --- Obtener todos los Soportes--- public function getAllSupports(Request $request, Response $response, array $args): Response { $stmt = $this->db->query("SELECT id_support, support FROM support ORDER BY support"); $supports = $stmt->fetchAll(PDO::FETCH_ASSOC); $response->getBody()->write(json_encode($supports, JSON_UNESCAPED_UNICODE)); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(200); } public function createSupport(Request $request, Response $response, array $args): Response { $data = $request->getParsedBody(); if (!isset($data['support']) || empty($data['support'])) { $response->getBody()->write(json_encode(['message' => 'El nombre del soporte es obligatorio.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } try { $stmt = $this->db->prepare("INSERT INTO support (support) VALUES (:support)"); $stmt->bindParam(':support', $data['support']); $stmt->execute(); $newId = $this->db->lastInsertId(); $response->getBody()->write(json_encode([ 'message' => 'Soporte creado con éxito.', 'id' => $newId, 'support' => $data['support'] ], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(201); } catch (PDOException $e) { if ($e->getCode() === '23000') { $response->getBody()->write(json_encode(['message' => 'El soporte ya existe.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(409); } $response->getBody()->write(json_encode(['message' => 'Error interno del servidor al crear el soporte.', 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(500); } } public function updateSupport(Request $request, Response $response, array $args): Response { $id = $args['id']; $data = $request->getParsedBody(); if (!isset($data['support']) || empty($data['support'])) { $response->getBody()->write(json_encode(['message' => 'El nombre del soporte es obligatorio para la actualización.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(400); } try { $stmt = $this->db->prepare("UPDATE support SET support = :support WHERE id_support = :id"); $stmt->bindParam(':support', $data['support']); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); if ($stmt->rowCount() === 0) { $response->getBody()->write(json_encode(['message' => 'Soporte no encontrado o sin cambios.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(404); } $response->getBody()->write(json_encode([ 'message' => 'Soporte actualizado con éxito.', 'id' => $id, 'support' => $data['support'] ], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json'); } catch (PDOException $e) { if ($e->getCode() === '23000') { $response->getBody()->write(json_encode(['message' => 'El soporte ya existe.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(409); } $response->getBody()->write(json_encode(['message' => 'Error interno del servidor al actualizar el soporte.', 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(500); } } public function deleteSupport(Request $request, Response $response, array $args): Response { $id = $args['id']; // --- Manejo de Integridad Referencial (FASE POSTERIOR, AHORA SOLO ELIMINA) --- // Para la fase 5, aquí iría la verificación de si hay películas usando este soporte. // Por ahora, simplemente intentamos eliminar. try { $stmt = $this->db->prepare("DELETE FROM support WHERE id_support = :id"); $stmt->bindParam(':id', $id, PDO::PARAM_INT); $stmt->execute(); if ($stmt->rowCount() === 0) { $response->getBody()->write(json_encode(['message' => 'Soporte no encontrado.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(404); } $response->getBody()->write(json_encode(['message' => 'Soporte eliminado con éxito.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json'); } catch (PDOException $e) { if ($e->getCode() === '23000') { $response->getBody()->write(json_encode(['message' => 'No se puede eliminar el soporte porque está asignado a una o más películas.'], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(409); } $response->getBody()->write(json_encode(['message' => 'Error interno del servidor al eliminar el soporte.', 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); return $response->withHeader('Content-Type', 'application/json')->withStatus(500); } }
<?php /** * * @About: API Interface * @File: index.php * @Date: $Date:$ Ene 2025 * @Version: $Rev:$ 1.0 * @Developer: Federico Guzman || Modificado por Fernando Humanes para PHP 8.3 **/ /* Los headers permiten acceso desde otro dominio (CORS) a nuestro REST API o desde un cliente remoto via HTTP * Removiendo las lineas header() limitamos el acceso a nuestro RESTfull API a el mismo dominio * Nótese los métodos permitidos en Access-Control-Allow-Methods. Esto nos permite limitar los métodos de consulta a nuestro RESTfull API * Mas información: https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS **/ // $dominioPermitido = "http://localhost:3000"; // header("Access-Control-Allow-Origin: $dominioPermitido"); // Para restringir desde dónde se pueden hacer peticines header("Access-Control-Allow-Origin: *"); header("Access-Control-Allow-Headers: Content-Type, authorization, Authorization, token-user "); // header("Access-Control-Allow-Headers: *"); header('Access-Control-Allow-Credentials: true'); header('Access-Control-Allow-Methods: PUT, GET, POST, DELETE, OPTIONS'); // header("Access-Control-Allow-Headers: X-Requested-With"); header('Content-Type: text/html; charset=utf-8'); header('Content-Type: multipart/form-data'); header('Content-Type: application/x-www-form-urlencoded'); header('Content-Type: application/json'); header('P3P: CP="IDC DSP COR CURa ADMa OUR IND PHY ONL COM STA"'); include_once '../include/Config.php'; // Configuration Rest Api // Debug // $debugCode = false; // custom_error(1,"URL ejecutada: ".$_SERVER["REQUEST_URI"]); // To debug // $debugCode = false; // use App\Models\Db; // Utilizamos la conexión de PHPRunner use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface as RequestHandler; use Slim\Factory\AppFactory; use Slim\Routing\RouteCollectorProxy; use Slim\Middleware\BodyParsingMiddleware; require_once __DIR__ . '/../libs/autoload.php'; // Library SLIM v4 $app = AppFactory::create(); $app->addRoutingMiddleware(); // $app->add(new BasePathMiddleware($app)); // No usar si se ejecuta en subdirectorio $app->addErrorMiddleware(true, true, true); $app->addBodyParsingMiddleware(); $app->setBasePath(SCRIPTS_DIR); // Indica el directorio desde donde está trabajando require_once '../include/DbMovies.php'; $db = new DbMovies(); // -------------------------------------------------------------------------------------- // Grupo de Rutas para Películas $app->group('/movies', function (RouteCollectorProxy $group) use ($db) { // Pasa la instancia de $db a tus controladores $group->get('', function (Request $request, Response $response, $args) use ($db) { // LIST return $db->getAllMovies($request, $response, $args); }); $group->get('/{id}', function (Request $request, Response $response, $args ) use ($db) { // VIEW return $db->getMovieById($request, $response, $args); }); $group->post('', function (Request $request, Response $response, $args) use ($db) { // ADD return $db->createMovie($request, $response, $args); }); $group->put('/{id}', function (Request $request, Response $response, $args) use ($db) { // UPDATE return $db->updateMovie($request, $response, $args); }); $group->delete('/{id}', function (Request $request, Response $response, $args) use ($db) { // DELETE return $db->deleteMovie($request, $response, $args); }); }); // Grupo de Rutas para Temas $app->group('/themes', function (RouteCollectorProxy $group) use ($db) { // Pasa la instancia de $db a tus controladores $group->get('', function (Request $request, Response $response, $args) use ($db) { // LIST return $db->getAllThemes($request, $response, $args); }); /* $group->get('/{id}', function (Request $request, Response $response, $args ) use ($db) { // VIEW return $db->getThemeById($request, $response, $args); }); */ $group->post('', function (Request $request, Response $response, $args) use ($db) { // ADD return $db->createTheme($request, $response, $args); }); $group->put('/{id}', function (Request $request, Response $response, $args) use ($db) { // UPDATE return $db->updateTheme($request, $response, $args); }); $group->delete('/{id}', function (Request $request, Response $response, $args) use ($db) { // DELETE return $db->deleteTheme($request, $response, $args); }); }); // Grupo de Rutas para Soportes $app->group('/supports', function (RouteCollectorProxy $group) use ($db) { // Pasa la instancia de $db a tus controladores $group->get('', function (Request $request, Response $response, $args) use ($db) { // LIST return $db->getAllSupports($request, $response, $args); }); /* $group->get('/{id}', function (Request $request, Response $response, $args ) use ($db) { // VIEW return $db->getSupportById($request, $response, $args); }); */ $group->post('', function (Request $request, Response $response, $args) use ($db) { // ADD return $db->createSupport($request, $response, $args); }); $group->put('/{id}', function (Request $request, Response $response, $args) use ($db) { // UPDATE return $db->updateSupport($request, $response, $args); }); $group->delete('/{id}', function (Request $request, Response $response, $args) use ($db) { // DELETE return $db->deleteSupport($request, $response, $args); }); }); /* Runner the aplication */ $app->run();
RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^(.*)$ %{ENV:BASE}index.php [QSA,L]
En esta parte de codificación del Server «movies-server», si que cualquier de las distintas AI’s te pueden ayudar y facilitar cualquier cosa que necesites, así como explicarte el código de este ejemplo. Creo, que para aquellos que tengan un poco de conocimiento de codificación en PHP, les será fácil leer el código del ejemplo.
Pienso que lo más «complejo» es la lectura del fichero «index.php», por eso paso a describiros algunas partes.
- Hasta línea 33, lo que hace es indicar qué tipo de peticiones puede recibir y desde qué IP’s o nombres de host.
- Líneas de 35 a 38, están comentadas y servirían para el sistema de depuración que explico en esta guía´.
- Líneas de 40 a 63, creación y configuración de los objetos de SLIM y la personalización de nuestra aplicación.
- Líneas de 64 a 82, son los métodos de acceso a los registros de la tabla «movies». En este caso se definen los métodos estándares de GET, PUT, POST y DELETE, pero se podrían configurar otros. Yo aconsejo utilizar los estándares porque así será más sencillos a los desarrolladores que deseen consumir estos servicios. El resto de bloques es lo mismo, pero para otras entidades de la Base de datos.
Ahora paso a describiros el código del front-end. Este es un ejemplo básico y quiero desarrollarlo mucho más, pero entiendo que puede ser el nivel idóneo para que entendáis el funcionamiento de los componentes de SVAR, paso inicial para que después podáis leer y entender los ejemplos que dispone esta empresa.
Todos los ficheros están en el directorio «src», salvo el fichero «main.js», que es el que inicia la aplicación,
«src/components«.- están los ficheros que tiene programado el CRUD de la tabla «movies». El fichero «StarCell.svelte» es el que dibuja las estrellas de la clasificación.
«src/lib«.- Tiene la clase y configuración para los accesos a «movies-server».
«src/locales«.- Tiene las traducciones de los textos de los componentes de SVAR que no disponen de una versión en Español.
«src/notification«.- Tiene el código y los recursos para hacer las notificaciones que se utilizan en la aplicación.
Paso a facilitaros los códigos más relevantes:
import { mount } from 'svelte' import App from './components/MovieGrid.svelte' const app = mount(App, { target: document.getElementById('app'), }) export default app
<script> import { onMount } from 'svelte'; import { ModalArea } from "wx-svelte-core"; // Ventana de Confirmación import { Grid } from 'wx-svelte-grid'; import { Button, Willow, Locale } from 'wx-svelte-core'; import { Editor } from 'wx-svelte-editor'; import { ContextMenu } from 'wx-svelte-menu'; import { registerEditorItem } from "wx-svelte-editor"; import { Text, Select, DatePicker, RichSelect } from "wx-svelte-core"; import { es as coreEs } from "wx-core-locales"; import { default as gridEs} from "../locales/esGrid.js"; import { default as editorEs } from "../locales/esEditor.js"; import Toasts from "../notification/Toasts.svelte"; import { addToast } from "../notification/store"; import StarCell from './StarCell.svelte'; import api from '../lib/api.js'; // let lang = "es"; // locale(lang); let gridRef = $state(); let selected = $state([]); // --- DATOS DEL BACKEND --- let movies = $state([]); // Aquí se cargarán las películas de la API let themes = $state([]); // Aquí se cargarán los temas de la API let supports = $state([]); // Aquí se cargarán los soportes de la API let loading = $state(true); let menuRef = $state(); let showEditor = $state(false); let editorValues = $state({}); let editorMode = $state("add"); // Registros necesarios para el Editor registerEditorItem("text", Text); registerEditorItem("number", Text); registerEditorItem("select", RichSelect); registerEditorItem("datepicker", DatePicker); // Ventana Modal de Confirmación let showDeleteConfirm = $state(false); let movieToDelete = $state(null); // Definición de columnas para el Editor let editorItems = $derived([ { key: "name", label: "Nombre", comp: "text", required: true, maxLength: 40 }, { key: "price", label: "Precio", comp: "number", required: true, validation: val => { const regEx = /^\d{1,5}(\.\d{1,2})?$/; // Se puede consultar a cualquier IA return val && regEx.test(val); // && val.length <= 40; }, validationMessage: "Introduce un número válido (máx. 5 enteros y 2 decimales)" }, { key: "startDate", label: "Fecha", comp: "datepicker", required: true }, { key: "rating", label: "Valoración", comp: "select", options: [1, 2, 3, 4, 5].map(n => ({ id: n, label: `${n} estrellas` })), required: true }, { key: "theme_id", label: "Tema", comp: "select", options: themes.map(t => ({ // cambio de los nomb res de los campos id: t.id_theme, label: t.theme })), required: true }, { key: "support_id", label: "Soporte", comp: "select", options: supports.map(s => ({ // Cambio de los nombres de los campos id: s.id_support, label: s.support })), required: true } ]); // Menú de contexto para el GRID const contextOptions = [ { id: "add", text: "Agregar", icon: "wxi-plus" }, { id: "edit", text: "Editar", icon: "wxi-edit" }, { id: "view", text: "Ver", icon: "wxi-eye" }, { id: "delete", text: "Eliminar", icon: "wxi-delete-outline" } ]; // Gestión de las Acciones de contexto del menú del GRID function handleContext(ev) { const id = gridRef.getState().selectedRows[0]; const row = id ? gridRef.getRow(id) : null; if (!Array.isArray(editorItems)) { console.warn("editorItems no es un array:", editorItems); } switch (ev.action?.id) { case "add": editorMode = "add"; editorValues = {}; showEditor = true; break; case "edit": if (row) { editorMode = "edit"; editorValues = { ...row }; showEditor = true; } break; case "view": if (row) { editorMode = "view"; editorValues = { ...row }; // Formateamos la fecha al formato que deseemos editorValues = { ...row, startDate: row.startDateText // Tomamos el campo formateado para mostrarlo en View }; showEditor = true; } break; case "delete": if (id) { const movie = movies.find(m => m.id === id); if (movie) { movieToDelete = movie; showDeleteConfirm = true; } } break; default: console.warn("Acción no reconocida:", ev.action); break; } } // Formateado de fechas para la Base de datos function toValidAPIDate(input) { const date = (input instanceof Date) ? input : new Date(input); return formatDateToAPI(date); } function formatDateToAPI(dateObj) { const year = dateObj.getFullYear(); const month = String(dateObj.getMonth() + 1).padStart(2, "0"); const day = String(dateObj.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } // Formar el Json de los campos que requiere el API function transformMoviePayload(values) { // Solo los campos que la API necesita const { name, price, startDate, rating, theme_id, support_id } = values; // Formatear fecha de "aaaa-mm-dd" → "aaaamm-dd" const formattedDate =toValidAPIDate(startDate); return { name, price, startDate: formattedDate, // Formateamos la fecha rating, theme_id, support_id }; } // Manejo de la acción del Editor // Aquí se maneja tanto el "add" como el "edit" async function handleEditorAction({ item, values }) { // loading = true; // Solo los campos que la API necesita const payload = transformMoviePayload(values); console.log("Payload para API:", payload); try { if (item.id === "save") { if (editorMode === "add") { // 🔄 Envío a la API para añadir const res = await api.post("/movies",payload); values.id = res.data.id_movie ?? Date.now(); // En caso de que la API devuelva el ID // movies.push(values); addToast({ message:'Película añadida correctamente' , type: 'success', // ["success", "error", "info"] dismissible: true, timeout: 2000 }); fetchAllData(); // Recargar datos después de la operación } else if (editorMode === "edit") { await api.put(`/movies/${values.id}`, payload); // movies = movies.map(m => (m.id === values.id ? values : m)); addToast({ message:'Película actualizada correctamente' , type: 'success', dismissible: true, timeout: 2000 }); fetchAllData(); // Recargar datos después de la operación } showEditor = false; } else if (item.id === "close" || item.id === "cancel") { showEditor = false; } } catch (err) { console.error("Error en operación de editor:", err); addToast({ // Notificación message: "No se pudo completar la operación.", type: 'error', // ["success", "error", "info"] dismissible: true, timeout: 0 }) } finally { // fetchAllData(); // Recargar datos después de la operación // loading = false; } } // Para la Acción de Delete function confirmDelete(movie) { movieToDelete = movie; showDeleteConfirm = true; } async function proceedDelete() { // loading = true; try { // Llamada al backend para borrar la película await api.delete(`/movies/${movieToDelete.id}`); // Eliminar del frontend movies = movies.filter(m => m.id !== movieToDelete.id); // Mostrar notificación addToast({ // Notificación message:'Película eliminada correctamente' , type: 'success', // ["success", "error", "info"] dismissible: true, timeout: 2000 }); } catch (err) { console.error("Error al eliminar película:", err); addToast({ // Notificación message: "No se pudo eliminar la película.", type: 'error', // ["success", "error", "info"] dismissible: true, timeout: 0 }); } finally { showDeleteConfirm = false; movieToDelete = null; // loading = false; } } function cancelDelete() { showDeleteConfirm = false; movieToDelete = null; } // Para acceder al registro seleccionado del GRID function resolver(id) { if (id) gridRef.exec("select-row", { id }); return id; } // --- Función para cargar todos los datos de la API --- async function fetchAllData() { // loading = true; try { const [resMovies, resThemes, resSupports] = await Promise.all([ api.get('/movies'), api.get('/themes'), api.get('/supports') ]); // Mapear datos de películas para coincidir con tu estructura de frontend // Asumimos que theme_id y support_id vienen como números de la API, // y que los nombres de theme/support están en propiedades 'theme' y 'support' dentro del objeto movie movies = resMovies.data.map(m => { const dateObj = new Date(m.startDate); // Para adapatr al GRID return { id: m.id_movie, name: m.name, price: parseFloat(m.price), startDate: dateObj, // 🔄 convierte string a Date startDateText: dateObj.toLocaleDateString("es-ES", { // Para Utilizar en los filros del GRID day: "2-digit", month: "2-digit", year: "numeric" }), rating: m.rating, theme: m.theme, // se usa para mostrar en el GRID support: m.support, // idem theme_id: m.theme_id, // se usa para editar en el formulario support_id: m.support_id }; }); // Para el Select themes= resThemes.data; console.log("Themes:", themes); /* themes = resThemes.data.map(t => ({ // Para cambiar losnombres de los campos id: t.id_theme, label: t.theme })); */ supports = resSupports.data; } catch (err) { console.error('Error al cargar datos:', err); addToast({ // Notificación message: 'No se pudieron cargar los datos. Por favor, intente de nuevo más tarde.', type: 'error', // ["success", "error", "info"] dismissible: true, timeout: 0 }); } finally { loading = false; } } // Cargar datos al iniciar el componente onMount(fetchAllData); function clearFilters() { gridRef.exec("filter-rows", {}); } function updateSelected() { selected = gridRef.getState().selectedRows; // console.log("Selected rows:", selected); } // Estilo de columna para alinear texto en el GRID const columnStyle = col => { if (col.id === "id") return "text-right"; if (col.id === "price") return "text-right"; if (col.id === "startDateText") return "text-center"; return ""; }; // Definición de columnas del Grid const columns = $derived([ { id: "id", header: "ID", width: 60, sort: true, resize: true }, { id: "name", header: [ "Nombre", { filter: { type: "text", config: { icon: "wxi-search", clear: true } } } ], flexgrow: 1, sort: true, resize: true }, { id: "price", header: [ "Precio", { filter: { type: "text", config: { icon: "wxi-search", clear: true, handler: (value, filter) => { // Filtro personalizado if (filter === "") return true; const num = parseFloat(filter); if (isNaN(num)) return false; return value >= num; } } } } ], type: "number", sort: true, width: 120, resize: true, template: value => new Intl.NumberFormat("en-US", { style: "currency", currency: "EUR", minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value) }, { id: "startDateText", header: [ "Fecha", { filter: { type: "text", config: { icon: "wxi-search", clear: true } } } ], width: 120, sort: true, resize: true, type: "text", /* template: value => new Date(value).toLocaleDateString("es-ES", { day: "2-digit", month: "2-digit", year: "numeric" }) */ }, { id: "rating", header: [ "Rating", { filter: { type: "richselect", config: { options: [1, 2, 3, 4, 5].map(n => ({ id: n, label: `${n} estrellas` })) } } } ], width: 130, sort: true, resize: true, cell: StarCell }, { id: "theme", type: "text", header: [ "Tema", { filter: { type: "richselect", // Type of filter (rich select dropdown) config: { options: themes.map(t => ({ id: t.theme, label: t.theme})), // Array of options for the dropdown (e.g., country list) // Los COMBOS, obligatoriamente, son "id" y "label" }, }, }, ], // options: themes, flexgrow: 1, sort: true, resize: true }, { id: "support_id", type: "combo", header: [ "Soporte", { filter: { type: "richselect", // Type of filter (rich select dropdown) config: { options: supports.map(s => ({ id: s.id_support, label: s.support})), // Array of options for the dropdown (e.g., country list) template: opt => `${opt.id}. ${opt.label}`, // Custom template for displaying options }, }, }, ], options: supports.map(s => ({ id: s.id_support, label: s.support})), flexgrow: 1, sort: true, resize: true } ]); // $inspect(gridRef).with(console.trace); // Para inspeccionar variables reactivas, aparece siempre que cambie valor </script> <Toasts /> <Willow> <div class="d-flex justify-content-between align-items-center mb-3"> <h2>🎬 Películas con Filtros y Ordenación</h2> <div class="d-flex gap-2"> <Button type="primary" text="🧹 Borrar filtros" onclick={clearFilters} /> <Button type="primary" text="➕ Agregar película" onclick={() => { editorMode = "add"; editorValues = {}; showEditor = true; }} /> </div> <div>Para el acceso a las acciones CRUD, botón derecho en el Grid</div> </div> {#if loading} <p class="text-center">Cargando películas...</p> {:else} <ContextMenu options={contextOptions} onclick={handleContext} at="point" resolver={resolver} api={gridRef} > <Locale words={{ ...gridEs, ...coreEs }}> <Grid bind:this={gridRef} data={movies} {columns} {columnStyle} pager={false} onselectrow={updateSelected} /> </Locale> </ContextMenu> {/if} {#if showEditor} <div class="variations"> <div> <!-- <h3>Cabecera del editor</h3> --> <!-- Sólo tiene sentido en {placement="inline"} --> <div class="bg"> <Locale words={{ ...editorEs, ...coreEs }}> <Editor header={true} placement="sidebar" layout="default" autoSave={false} readonly={editorMode === "view"} items={editorItems} values={editorValues} onaction={handleEditorAction} /> </Locale> </div> </div> </div> {/if} <!-- Modal de confirmación Borrado --> {#if showDeleteConfirm} <ModalArea> <div class="modal-content"> <h3>¿Eliminar esta película?</h3> <p>Nombre: <b>{movieToDelete?.name}</b></p> <p>Fecha: {movieToDelete?.startDateText}</p> <div class="actions"> <Button type="danger" onclick={proceedDelete}>Confirmar</Button> <Button onclick={cancelDelete} color="secondary">Cancelar</Button> </div> </div> </ModalArea> {/if} </Willow> <style> .modal-content { padding: 30px; text-align: center; background-color: #f1f5f9; /* gris claro elegante */ border: 2px solid #f97316; /* borde naranja suave */ border-radius: 0px; /* esquinas redondeadas */ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); /* sombra profunda */ font-family: "Segoe UI", "Roboto", sans-serif; max-width: 400px; margin: 0 auto; } .modal-content h3 { font-size: 1.3rem; margin-bottom: 20px; color: #1e293b; /* texto oscuro elegante */ } .modal-content p { margin: 10px 0; font-size: 1rem; color: #334155; } .actions { margin-top: 30px; display: flex; justify-content: center; gap: 20px; } :global(.wx-toast) { border-radius: 8px; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); font-weight: 500; font-family: "Segoe UI", sans-serif; padding: 10px 20px; } :global(.wx-cell.text-right) { text-align: right; } :global(.wx-cell.text-center) { text-align: center; } .variations { display: flex; flex-direction: row; flex-wrap: wrap; } .variations > div { margin: 0 20px 60px 20px; width: 400px; } .bg { border-top: 1px solid #ccc; padding: 10px; height: 100%; width: 300px; } :global(.wx-sidearea) { /* Cambio del tamaño de área de edición */ width: 300px; } :global(.wx-willow-theme) { --wx-table-select-background: #eaedf5; --wx-table-select-color: var(--wx-color-font); --wx-table-border: 1px solid #e6e6e6; --wx-table-select-border: inset 3px 0 var(--wx-color-primary); --wx-table-header-border: var(--wx-table-border); --wx-table-header-cell-border: var(--wx-table-border); --wx-table-footer-cell-border: var(--wx-table-border); --wx-table-cell-border: var(--wx-table-border); --wx-header-font-weight: 600; --wx-table-header-background: #f2f3f7; --wx-table-fixed-column-border: 3px solid #e6e6e6; --wx-table-editor-dropdown-border: var(--wx-table-border); --wx-table-editor-dropdown-shadow: 0px 4px 20px rgba(44, 47, 60, 0.12); --wx-table-drag-over-background: var(--wx-background-alt); --wx-table-drag-zone-shadow: var(--wx-box-shadow); } </style>
<script> let { row } = $props(); const stars = Array.from({ length: 5 }, (_, i) => i < row.rating ? "★" : "☆" ); </script> <div class="star-cell"> {#each stars as s, i} <span class={i < row.rating ? "filled" : "empty"}>{s}</span> {/each} </div> <style> .star-cell { text-align: center; font-size: 1.5em; } .filled { color: gold; } .empty { color: lightgray; } </style>
// src/lib/api.js (o src/api/index.js) import axios from 'axios'; let appHost = ''; appHost = window.location.hostname; // Para obtener el host de la URL console.log("estoy conectado al Host: ",appHost); // URL base de tu API REST de PHP // Asegúrate de que esta URL sea la correcta para tu PUBLIC folder let API_BASE_URL = 'http://localhost/movie-server/v1'; if (appHost == 'localhost') { API_BASE_URL = 'http://localhost/movie-server/v1'; } else { API_BASE_URL = 'https://fhumanes.com/movie-server/v1'; } const api = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', }, }); export default api;
export default { editor: { "This field is required": "Este campo es obligatorio", "Invalid value": "Valor inválido", Yes: "Sí", No: "No", Save: "Guardar", Cancel: "Cancelar", "No data": "No hay datos", }, };
export default { grid: { "Add before": "Añadir antes del registro", "Add after": "Añadir después del registro", Copy: "Copiar", Delete: "Eliminar", }, };
Como os he indicado, voy a ampliar funcionalidad a la parte del front-end, pero creo que los ficheros del directorio «src/components», están llenos de comentarios y pienso que se pueden entender, no obstante si algún aspecto os resulta complejo, por favor, avisadme en mi email [email protected] e intentaré resolver vuestras dudas.
Os dejo los fuentes, tanto del Front-end, como del Back-end.