martes, 10 de junio de 2014

Dynamic data binding spring web flow

Pojo:
 
 protected List telefonos = new ArrayList();
Html:
 
   
    
   
   
   

miércoles, 9 de abril de 2014

Spring Web Flow - Prevenir el doble click cuando enviamos un formulario html pero sin dejar de enviar el botón presionado

En la mayoría de aplicaciones muchas veces es necesario des-habilitar el botón tipo submit del un formulario html que se encarga de captura de datos para posteriormente persistir estos en la base de datos.

En mi caso particular desarrollando una aplicación utilizando spring web flow, me fue necesario des-habilitar el botón que enviaba el formulario al servidor pero resulta que al des-habilitar el botón, este no es enviado con el formulario y para mi caso, spring web flow necesita saber el nombre del botón presionado para saber por donde continuar su flujo en el archivo de configuración del flujo.

Extracto de mi archivo de configuración del flujo:

 
  
  
  
 

 
  
  
  
 

 
  
 

Pueden observar que en el estado de la vista form-datos-envio, tenemos una transicion de nombre save la cual ejecutara la accion de guardar.

Para hacer esto, create una funcion que valide el formulario al momento del submit y utilizare jquery en la vista conjuntamente con un input hidden en el cual guardare el nombre del botón presionado y así poder des-habilitar el botón pero enviar su nombre mediante un campo oculto.

Botones necesarios en la vista:

    
    

    
    

    
    

Campo oculto donde almacenare el valor del boton presionado:



Jquery:

 

Validacion del formulario en el evento onsubmit:

  

viernes, 21 de marzo de 2014

Autenticación LDAP Apache Debian

Una mínima configuración para proteger el acceso al servidor apache2 en debian.

Contenido:
1-Configuracion del archivo httpd.conf
Para los efectos de este blog, modificare el archivo httpd.conf. Para configuracion avanzadas, con host virtuales, deberan leer otro tipo de documentacion.
Archivo httpd.conf:
LoadModule ldap_module /usr/lib/apache2/modules/mod_ldap.so
LoadModule authnz_ldap_module /usr/lib/apache2/modules/mod_authnz_ldap.so

<Directory /var/www>
   AuthType Basic
   AuthBasicProvider ldap
   AuthzLDAPAuthoritative off
   AuthName "Autenticacion con Active Directory"
   AuthLDAPURL "ldap://mi.ip.com:389/ou=Unidad Organizativa,dc=dc1,dc=dc2?sAMAccountName?sub?(objectClass=*)"
   AuthLDAPBindDN "cn=system,ou=SistemasAdministrativos,ou=Unidad Organizativa,dc=dc1,dc=dc2"
   AuthLDAPBindPassword "systemPassword"
   require valid-user
</Directory>

2-Autenticación sobre el servidor
Al intentar acceder al servidor se nos pedira nuestro usuario y password de active directory

jueves, 13 de marzo de 2014

Herramientas para desarrolladores: JsonView

JSONView for Chrome valida texto en formato json. La validación se realiza usando implementacion javascript de JsonLint.

El texto en formato json es extraído de la pagina que se esta desplegando en el navegador y visualizado de una manera como si se estuviera utilizando la etiqueta <pre> de html.

Ejemplo de la extensión en funcionamiento:


Extracción de texto desde una imagen utilizando tesseract-ocr engine

Tesseract es probablemente el motor de OCR de fuente abierta más exacto disponible. En combinación con la Biblioteca de procesamiento Imagen Leptonica, puede leer una amplia variedad de formatos de imagen y los convierte a texto en más de 60 idiomas. Fue uno de los 3 mejores motores en el test de precisión de UNLV  en 1995. Entre 1995 y 2006 tuvo poco trabajo hecho en él, pero desde entonces se ha mejorado ampliamente por Google. Es liberado bajo la licencia Apache 2.0.

Fuente: https://github.com/tesseract-ocr/tesseract

A continuación, mediante una simple aplicación consola de java les mostrare paso a paso como hacer uso de este poderoso motor.

Contenido:

  1. Descargar la libreria.
  2. Estructura del proyecto en eclipse
  3. Escanear una imagen y definicion de la region a realizar la extraccion del texto.
  4. Desarrollo de la aplicacion.
  5. Ejecucion y verificacion de los resultados.

2. Estructura del proyecto en eclipse. Ver imagen a continuacion de como se debería ver tu proyecto.

3. Para mi ejemplo he escaneado el documento de identidad en mi país y he definido manualmente la región de extracción donde se encuentra el numero identificador. La región encerrada con el recuadro rojo, es la región a obtener mediante el motor tesseract.

4. Solo cree un archivo donde tengo un metodo principal y alli defino el lenguaje a utilizar, en mi caso español, creo un objeto Rectangle con las coordenadas de la región a realizar la extracción e imprimo por consola el texto procesado.

public class TesseractUtil {

 /**
  * Region
  */
 private static Rectangle DEFAULT_RECTANGLE_NUMERO_CEDULA_SIZE = new Rectangle(
   340, 118, 300, 60);

 private static String TYPE_DEFAULT_IMAGE = "jpg";

 private static String DEFAULT_LANGUAJE = "spa";

 public static void main(String[] args) throws IOException,
   TesseractException {
  File file = new File("C:\\Users\\usuario\\Pictures\\williams.jpg");
  System.out.println("Identificador: " + getIdentificadorCedula(file));
 }

 public static String getIdentificadorCedula(File imageFile)
   throws IOException, TesseractException {
  Tesseract1 instance = new Tesseract1(); // JNA Direct Mapping
  instance.setLanguage(DEFAULT_LANGUAJE);
  BufferedImage image = bufferedImage(imageFile);
  makeGray(image);
  String result = instance.doOCR(image,
    DEFAULT_RECTANGLE_NUMERO_CEDULA_SIZE);
  result = cleanIdentificadorCedula(result);
  return result;
 }

 public static BufferedImage bufferedImage(File file) throws IOException {
  return ImageIO.read(file);
 }

 public static String cleanIdentificadorCedula(String identificadorCedula) {
  String finalString = StringUtils.deleteWhitespace(identificadorCedula)
    .trim().toUpperCase();
  return finalString.replaceAll("[^\\d]", "");
 }

 public static BufferedImage cropImage(BufferedImage src, Rectangle rect) {
  BufferedImage dest = src.getSubimage(rect.x, rect.y, rect.width,
    rect.height);
  return dest;
 }

 public static void saveImage(String outputFolder, String filename,
   BufferedImage image, String type) throws IOException {
  File folder = new File(outputFolder);
  if (!folder.exists()) {
   folder.mkdir();
  }
  folder = new File(outputFolder);
  File save_path = new File(folder.getPath() + "\\" + filename + "."
    + TYPE_DEFAULT_IMAGE);
  ImageIO.write(image, type, save_path);
 }

 public static void makeGray(BufferedImage img) {
  for (int x = 0; x < img.getWidth(); ++x)
   for (int y = 0; y < img.getHeight(); ++y) {
    int rgb = img.getRGB(x, y);
    int r = (rgb >> 16) & 0xFF;
    int g = (rgb >> 8) & 0xFF;
    int b = (rgb & 0xFF);
    int gray = (r + g + b) / 3;
    img.setRGB(x, y, gray);
   }
 }

}

5. Salida por la consola:
Identificador: 55555555

miércoles, 12 de marzo de 2014

Utilizando la API de JAVA.NET para leer data en formato JSON desde una URL

Java.net permite realizar conexiones y transacciones a través de la red. Utilizando el paquete java.net podemos comunicar dos o más computadoras que estén en distintas partes del mundo.

En nuestro código de ejemplo vamos a utilizar la clase HttpURLConnection la cual extiende de la clase URLConnection y da soporte especifico al protocolo HTTP.

Ejemplo:

public static String httpGet(String stringUrl) throws HttpGetException{
  URL url;
  try {
   url = new URL(stringUrl);
  } catch (MalformedURLException e2) {
   throw new HttpGetException(e2.getCause());
  }
  HttpURLConnection urlConnection = null;
  String dataJson = "";
  try {
   urlConnection = (HttpURLConnection) url.openConnection();
   urlConnection.setConnectTimeout(5000); // set timeout to 5 seconds
   BufferedReader in = new BufferedReader(new InputStreamReader(
     urlConnection.getInputStream(), StandardCharsets.UTF_8));
   StringBuffer sb = new StringBuffer();
   String inputLine;
   while ((inputLine = in.readLine()) != null) {
    sb.append(inputLine);
   }
   dataJson = sb.toString();
  } catch (java.net.SocketTimeoutException e) {
   throw new HttpGetException(e);
  } catch (IOException e) {
   if (urlConnection instanceof HttpURLConnection) {
    HttpURLConnection httpConn = (HttpURLConnection) urlConnection;
    InputStream in = null;
    try {
     in = httpConn.getErrorStream();
     StringBuffer buf = new StringBuffer();
     byte[] cbuf = new byte[1024 * 64];
     int r = in.read(cbuf);
     while (r > -1) {
      if (r > 0) {
       buf.append(new String(cbuf, 0, r));
      }
      r = in.read(cbuf);
     }
     YoutubeJsonError jsonError = new YoutubeJsonError(
       buf.toString());
     throw new HttpGetException(jsonError.getMessage());

    } catch (IOException e1) {
     e1.printStackTrace();
    } finally {
     if (in != null) {
      try {
       in.close();
      } catch (IOException e1) {
       e1.printStackTrace();
      }
     }
    }

   }
  } finally {
   if (urlConnection != null) {
    urlConnection.disconnect();
   }
  } 
  return dataJson;
 }
}

Probando el método con la siguiente url:
http://ip.jsontest.com/

y con el metodo:
System.out.println(dataJson);


Deberia mostrar el siguiente texto:
{"ip": "8.8.8.8"} donde los numeros ochos representaran la direccion de tu ip en la internet.

martes, 11 de marzo de 2014

YouTube API V3 + Spring Roo

En este post voy a explicar cómo obtener los videos subidos a un canal youtube desde una aplicación web desarrollada con spring mvc. También explicare como obtener información relacionada al video: duración, cantidad de reproducciones… y como hacer un paginador del listado de videos.

Para todo esto vamos a utilizar la api que nos provee google: YouTube Data API (v3). Esta api nos permite incorporar funcionalidades de youtube en nuestra propia aplicación. Se puede utilizar la api para buscar videos, insertar, actualizar y borrar recursos tales como videos o listas de reproducciones de video.

Contenido:

  1. Registrar nuestra aplicación para obtener una clave publica de google.
  2. Definición del modelo para convertir la data json a objetos java.
  3. Servicio spring que contendrá la api necesaria para la búsqueda de videos.
  4. Definición del controlador que recibirá las peticiones realizadas por el usuario.
  5. Diseño de la vista para mostrar la lista de videos de manera paginada.
  6. Vista de la aplicación.
1. Registrar nuestra aplicación para obtener una clave publica de google.

2. Definición del modelo para convertir la data json a objetos java: Vamos a crear dos clases java, una para manejar la información relacionada a la lista de videos retornada por google y otra clase para manejar la información del video.
public class VideoYoutube {
 private String videoId;

 private Date publischedAt;

 private String channelId;

 private String title;

 private String description;

 private String thumbnail;

 private String channelTitle;

 private String duration;

 private Long viewCount;

 public VideoYoutube() {
  this.videoId = "";
  this.publischedAt = null;
  this.channelId = "";
  this.title = "";
  this.description = "";
  this.thumbnail = "";
  this.channelTitle = "";
  this.duration = "";
  this.viewCount = null;
 }

 public VideoYoutube(String videoId, Date publischedAt, String channelId,
   String title, String description, String thumbnail,
   String channelTitle, String duration, Long viewCount) {
  super();
  this.videoId = videoId;
  this.publischedAt = publischedAt;
  this.channelId = channelId;
  this.title = title;
  this.description = description;
  this.thumbnail = thumbnail;
  this.channelTitle = channelTitle;
  this.duration = duration;
  this.viewCount = viewCount;
 }

 public String getVideoId() {
  return videoId;
 }

 public void setVideoId(String videoId) {
  this.videoId = videoId;
 }

 public Date getPublischedAt() {
  return publischedAt;
 }

 public void setPublischedAt(Date publischedAt) {
  this.publischedAt = publischedAt;
 }

 public String getChannelId() {
  return channelId;
 }

 public void setChannelId(String channelId) {
  this.channelId = channelId;
 }

 public String getTitle() {
  return title;
 }

 public void setTitle(String title) {
  this.title = title;
 }

 public String getDescription() {
  return description;
 }

 public void setDescription(String description) {
  this.description = description;
 }

 public String getThumbnail() {
  return thumbnail;
 }

 public void setThumbnail(String thumbnail) {
  this.thumbnail = thumbnail;
 }

 public String getChannelTitle() {
  return channelTitle;
 }

 public void setChannelTitle(String channelTitle) {
  this.channelTitle = channelTitle;
 }

 public String getDuration() {
  if (duration != null && !duration.equals("")) {
   StringBuilder strPeriod = new StringBuilder();
   PeriodFormatter formatter = ISOPeriodFormat.standard();
   Period period = formatter.parsePeriod(duration);
   if (period.getHours() > 0) {
    strPeriod.append(period.getHours()).append(":");
   }
   if (period.getMinutes() < 10) {
    strPeriod.append("0").append(period.getMinutes()).append(":");
   } else {
    strPeriod.append(period.getMinutes()).append(":");
   }
   if (period.getSeconds() < 10) {
    strPeriod.append("0").append(period.getSeconds());
   } else {
    strPeriod.append(period.getSeconds());
   }
   return strPeriod.toString();
  }
  return duration;
 }

 public void setDuration(String duration) {
  this.duration = duration;
 }

 public Long getViewCount() {
  return viewCount;
 }

 public void setViewCount(Long viewCount) {
  this.viewCount = viewCount;
 }

}

public class PageResultYoutube {
 private String nextPageToken;

 private String prevPageToken;

 private int totalResults;

 private int resultsPerPage;

 private Map videosResults;

 public PageResultYoutube(String nextPageToken, String prevPageToken,
   int totalResults, int resultsPerPage,
   Map videosResults) {
  super();
  this.nextPageToken = nextPageToken;
  this.prevPageToken = prevPageToken;
  this.totalResults = totalResults;
  this.resultsPerPage = resultsPerPage;
  this.videosResults = videosResults;
 }

 public PageResultYoutube(String dataJson) {
  JSONObject jo = new JSONObject(dataJson);
  if (jo.has("nextPageToken")) {
   nextPageToken = jo.getString("nextPageToken");
  }
  if (jo.has("prevPageToken")) {
   prevPageToken = jo.getString("prevPageToken");
  }
  if (jo.has("pageInfo")) {
   JSONObject pageInfo = jo.getJSONObject("pageInfo");
   totalResults = pageInfo.getInt("totalResults");
   resultsPerPage = pageInfo.getInt("resultsPerPage");
  }
  videosResults = new HashMap();
  if (jo.has("items")) {
   JSONArray items = jo.getJSONArray("items");
   VideoYoutube video;
   for (int i = 0; i < items.length(); i++) {
    video = new VideoYoutube();
    JSONObject item = items.getJSONObject(i);
    JSONObject id = item.getJSONObject("id");
    JSONObject snippet = item.getJSONObject("snippet");   
    video.setVideoId(id.getString("videoId"));
    video.setChannelId(snippet.getString("channelId"));
    video.setChannelTitle(snippet.getString("channelTitle"));
    video.setDescription(snippet.getString("description"));
    video.setPublischedAt(new DateTime(snippet
      .getString("publishedAt")).toDate());
    video.setThumbnail(snippet.getJSONObject("thumbnails")
      .getJSONObject("default").getString("url"));
    video.setTitle(snippet.getString("title"));
    videosResults.put(video.getVideoId(), video);
   }

  }

 }

 public String getVideoIdsSeparetedByComma() {
  StringBuilder str = new StringBuilder();
  if (videosResults != null && videosResults.size() > 0) {
   for (Entry entry : videosResults.entrySet()) {
    str.append(entry.getKey()).append(",");
   }
  }// XXX fix validation
  String finalStr = str.toString();
  finalStr = finalStr.substring(0, finalStr.toString().length() - 1);
  return finalStr;
 }

 public void addVideo(VideoYoutube video) {
  videosResults.put(video.getVideoId(), video);
 }

 public String getNextPageToken() {
  return nextPageToken;
 }

 public void setNextPageToken(String nextPageToken) {
  this.nextPageToken = nextPageToken;
 }

 public String getPrevPageToken() {
  return prevPageToken;
 }

 public void setPrevPageToken(String prevPageToken) {
  this.prevPageToken = prevPageToken;
 }

 public int getTotalResults() {
  return totalResults;
 }

 public void setTotalResults(int totalResults) {
  this.totalResults = totalResults;
 }

 public int getResultsPerPage() {
  return resultsPerPage;
 }

 public void setResultsPerPage(int resultsPerPage) {
  this.resultsPerPage = resultsPerPage;
 }

 public Map getVideosResults() {
  return videosResults;
 }

 public void setVideosResults(Map videosResults) {
  this.videosResults = videosResults;
 }

 public void setContentDetailsVideos(String contentDetailsJson) {
  JSONObject jo = new JSONObject(contentDetailsJson);
  if (jo.has("items")) {
   JSONArray items = jo.getJSONArray("items");
   for (int i = 0; i < items.length(); i++) {
    JSONObject item = items.getJSONObject(i);
    VideoYoutube video = videosResults.get(item.get("id"));
    JSONObject cd = item.getJSONObject("contentDetails");
    video.setDuration(cd.getString("duration"));
    JSONObject stats = item.getJSONObject("statistics");
    video.setViewCount(stats.getLong("viewCount"));
    videosResults.put(video.getVideoId(), video);
   }
  }
 }

}


3. Servicio spring que contendrá la api necesaria para la búsqueda de videos.
/**
 * 
 * @author Williams Rivas Created 20/02/2014 12:59:49
 * 
 */
@Service
public class YoutubeServiceImpl implements YoutubeService {

 public static final String PARAM_MAX_RESULTS = "maxResults";

 private static int DEFAULT_VALUE_MAX_RESULTS = 16;

 private static String DEFAULT_VALUE_CHANNEL_ID = “XXXXXXXXXXXXXXXXXXXXXX";

 public static final String PARAM_PART = "part";

 public static final String DEFAULT_VALUE_PART = "snippet";

 public static final String DEFAULT_VALUE_PARTS_VIDEOS = "contentDetails%2Cstatistics";

 public static final String PARAM_PAGE_TOKEN = "pageToken";

 public static final String PARAM_CHANNEL_ID = "channelId";

 public static final String PARAM_VIDEO_IDS = "id";

 public static final String PARAM_KEY = "key";

 private static final String DEFAULT_VALUE_KEY = "API_KEY_GENERADA_EN_EL_PASO_UNO_DEL_CONTENIDO_DE_ESTE_POST";

 public static final String URL_SEARCH_YOUTUBE = "https://www.googleapis.com/youtube/v3/search?type=video&";

 public static final String URL_VIDEOS_LIST = "https://www.googleapis.com/youtube/v3/videos?";

 public static final String PARAM_VALUE_SEPARATOR = "=";

 public static final String PARAM_VALUE_CONCAT = "&";

 public static final String PARAM_VALUE_COMMA = "%2C";

 @Autowired
 protected YoutubeCanalRepository youtubeCanalRepository;

 @Override
 public PageResultYoutube searchYoutubeVideos(String part, String channelId,
   int maxResults, String pageToken) throws SearchYoutubeException {
  setDefaultValues();
  String query = buildQueryUrl(part, channelId, maxResults, pageToken,
    null);
  String dataJson = Util.httpGet(query);

  PageResultYoutube page = new PageResultYoutube(dataJson);

  String videoIds = page.getVideoIdsSeparetedByComma();

  String contentDetailsJson = "";
  try {
   contentDetailsJson = Util.httpGet(buildQueryUrl(
     DEFAULT_VALUE_PARTS_VIDEOS, null, DEFAULT_VALUE_MAX_RESULTS,
     null, videoIds));   
  } catch (HttpGetException e) {
   throw new SearchYoutubeException(e.getMessage());
  }

  page.setContentDetailsVideos(contentDetailsJson);

  return page;
 }

 @Override
 public PageResultYoutube searchYoutubeVideos(String part, String channelId,
   int maxResults) throws SearchYoutubeException {
  return searchYoutubeVideos(part, channelId, maxResults, null);
 }

 @Override
 public PageResultYoutube searchYoutubeVideos()
   throws SearchYoutubeException {
  return searchYoutubeVideos(DEFAULT_VALUE_PART,
    DEFAULT_VALUE_CHANNEL_ID, DEFAULT_VALUE_MAX_RESULTS);
 }

 private String buildQueryUrl(String part, String channelId, int maxResults,
   String pageToken, String videoIds) {
  StringBuilder url;
  if (channelId != null && !channelId.equals("")) {
   url = new StringBuilder(URL_SEARCH_YOUTUBE);
   // parametro part
   url.append(PARAM_PART).append(PARAM_VALUE_SEPARATOR);
   if (part != null && !part.equals("")) {
    if (!part.equals(DEFAULT_VALUE_PART)) {
     url.append(DEFAULT_VALUE_PART);
    } else {
     url.append(part);
    }
   } else {
    url.append(DEFAULT_VALUE_PART);
   }

   // parametro channel id
   url.append(PARAM_VALUE_CONCAT).append(PARAM_CHANNEL_ID)
     .append(PARAM_VALUE_SEPARATOR);
   if (channelId != null && !channelId.equals("")) {
    url.append(channelId);
   } else {
    url.append(DEFAULT_VALUE_CHANNEL_ID);
   }
   // pagina
   if (pageToken != null && !pageToken.equals("")) {
    url.append(PARAM_VALUE_CONCAT).append(PARAM_PAGE_TOKEN)
      .append(PARAM_VALUE_SEPARATOR).append(pageToken);
   }
  } else { // busqueda no por canal sino por ids de videos
     // https://www.googleapis.com/youtube/v3/videos?part=snippet%2CcontentDetails%2Cstatistics&id=uWKM4F2RAtI%2CJ2NIttHwZBA%2C6UliPT1LIc4%2CSaLWwDyHMvo&maxResults=3&key=AIzaSyBB8x12DXzrzXKhkum5f_Nv3Yl7-0GSwCg
   url = new StringBuilder(URL_VIDEOS_LIST);

   // parametro part
   url.append(PARAM_PART).append(PARAM_VALUE_SEPARATOR);
   if (part != null && !part.equals("")) {
    if (!part.equals(DEFAULT_VALUE_PARTS_VIDEOS)) {
     url.append(DEFAULT_VALUE_PARTS_VIDEOS);
    } else {
     url.append(part);
    }
   } else {
    url.append(DEFAULT_VALUE_PARTS_VIDEOS);
   }
   // videos ids separados por comma
   if (videoIds != null && !videoIds.equals("")) {
    url.append(PARAM_VALUE_CONCAT).append(PARAM_VIDEO_IDS)
      .append(PARAM_VALUE_SEPARATOR);
    url.append(videoIds);
   }
  }

  // max results
  url.append(PARAM_VALUE_CONCAT).append(PARAM_MAX_RESULTS)
    .append(PARAM_VALUE_SEPARATOR);
  if (maxResults <= 0) {
   url.append(DEFAULT_VALUE_MAX_RESULTS);
  } else {
   url.append(maxResults);
  }

  // key developer
  url.append(PARAM_VALUE_CONCAT).append(PARAM_KEY)
    .append(PARAM_VALUE_SEPARATOR).append(DEFAULT_VALUE_KEY);

  return url.toString();
 }

 @Override
 public PageResultYoutube searchYoutubeVideos(String pageToken)
   throws SearchYoutubeException {
  return searchYoutubeVideos(DEFAULT_VALUE_PART,
    DEFAULT_VALUE_CHANNEL_ID, DEFAULT_VALUE_MAX_RESULTS, pageToken);
 }

 private void setDefaultValues() {
  List canals = youtubeCanalRepository.findAll();
  if (canals != null && canals.size() > 0) {
   DEFAULT_VALUE_CHANNEL_ID = canals.get(0).getIdChannel();
   DEFAULT_VALUE_MAX_RESULTS = canals.get(0).getMaxResults();
  }
 }
}


4. Definición del controlador que recibirá las peticiones realizadas por el usuario.
@RequestMapping("/youtube/**")
@Controller
public class YoutubeController extends GlobalModelAttributes {

 @Autowired
 private YoutubeService youtube;

 @RequestMapping(method = RequestMethod.POST, value = "{id}")
 public void post(@PathVariable Long id, ModelMap modelMap,
   HttpServletRequest request, HttpServletResponse response) {
 }

 @RequestMapping
 public String index(
   @RequestParam(value = "page", required = false) String page,
   ModelMap uiModel) {
  PageResultYoutube result;
  if (page != null) {
   result = youtube.searchYoutubeVideos(page);
  } else {
   result = youtube.searchYoutubeVideos();
  }
  uiModel.addAttribute("youtube", result);
  return "youtube/index";
 }
}


5. Diseño de la vista para mostrar la lista de videos de manera paginada.

Videos Youtube



6. Vista de la aplicación