--- marp: true theme: default paginate: true backgroundColor: #fff footer: WEB : Ruby - Semaine 6 - Active Record / Sessions / Authentification --- ![bg right:40% 40%](../shared/images/Ruby_On_Rails_Logo.svg) # ActiveRecord / Sessions * inverse_of * Includes * Join * Cookies * Session * Authentification --- ### Associations bi-directionnelle : Détection automatique * Rails détecte les associations bi-directionnelle à partir du nom des associations. ```ruby class Author < ApplicationRecord has_many :books end class Book < ApplicationRecord belongs_to :author end ``` ```ruby irb> author = Author.first irb> book = authors.books.first irb> author.first_name == book.first.author.first_name # => true irb> author.first_name = 'David' irb> author.first_name == book.author.first_name # => true ; author.object_id == book.author.object_id ``` * Active Record ne charge qu'une seule copie de l'objet Author, ça évite une requête à la base de donnée, et évite les données incohérentes. --- ### Associations bi-directionnelle : Détection automatique impossible * Rails ne parvient pas à determiner les associations inverses quand on utilise `:through`, `:foreign_key`, `:order`, ```ruby class Author < ApplicationRecord has_many :books, inverse_of: 'writer' end class Book < ApplicationRecord belongs_to :writer, class_name: 'Author', foreign_key: 'author_id' end ``` ```ruby irb> author = Author.first irb> book = authors.books.first irb> author.first_name == book.first.author.first_name # => true irb> author.first_name = 'David' irb> author.first_name == book.author.first_name # => false ; author.object_id != book.author.object_id ``` --- ### Associations bi-directionnelle : inverse_of * `inverse_of` permet d'indiquer quelle est la relation inverse d'un `belongs_to`, `has_one`, `has_many` ```ruby class Author < ApplicationRecord has_many :books, inverse_of: 'writer' end class Book < ApplicationRecord belongs_to :writer, class_name: 'Author', foreign_key: 'author_id' end ``` ```ruby irb> author = Author.first irb> book = authors.books.first irb> author.first_name == book.first.author.first_name # => true irb> author.first_name = 'David' irb> author.first_name == book.author.first_name # => true ; author.object_id == book.author.object_id ``` --- ### Includes : eager loader les données * Eviter le N+1 ```erb <% Message.limit(25).each do |message| %>

<%= message.title %> <%= message.author.name %>

<% end %> # (0.2ms) SELECT "messages".* FROM "messages" # (0.2ms) SELECT "authors".* FROM "authors" WHERE "author"."id" = ? # (0.2ms) SELECT "authors".* FROM "authors" WHERE "author"."id" = ? # (0.2ms) SELECT "authors".* FROM "authors" WHERE "author"."id" = ? .... # => 26 requêtes ``` --- ### Includes : eager loader les données * Toutes les associations spécifiées sont chargées en utilisant le nombre minimum de requêtes possible. ```erb <% Message.includes(:author).limit(10).each do |message| %>

<%= message.title %> <%= message.author.name %>

<% end %> # (0.2ms) SELECT "messages".* FROM "messages" # (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" IN (?, ?, ..) # => 2 requêtes ``` --- ### Includes : eager loader les associations * On peut spécifier une liste d'associations ```ruby Message.includes(:author, :comments) ``` * Et des associations imbriquées ```ruby Customer.includes(orders: {books: [:supplier, :author]}).find(1) ``` --- ### Join : INNER JOIN * Jointure à partir d'une ou plusieurs associations ```ruby Book.joins(:comments) # Les livres ayant des commentaires # SELECT books.* FROM books # INNER JOIN comments ON comments.book_id = books.id ``` * Récupère un livre pour chaque livre avec un commentaire * Attention aux doublons, pour les éviter : `Book.joins(:comments).distinct` * Jointure à partir de plusieurs associations ```ruby Book.joins(:author, :comments) # Les livres avec leur auteur qui ont au moins un commentaire # SELECT books.* FROM books # INNER JOIN authors ON authors.id = books.author_id # INNER JOIN comments ON comments.book_id = books.id ``` --- ### Join : INNER JOIN * Jointures imbriquées ```ruby Book.joins(comments: :customer) # Les livres qui ont un commentaire par un client # SELECT books.* FROM books # INNER JOIN reviews ON reviews.book_id = books.id # INNER JOIN customers ON customers.id = reviews.customer_id ``` * Spécifier des conditions ```ruby Book.joins(:comments).where('comments.created_at' => (1.week.ago..Time.now).distinct # Les livres uniques avec des commentaires datant de moins d'une semaine. Book.joins(:author).where('author.first_name' => "Nabilla" ).distinct # Les livres uniques avec un auteur qui se prénomme Nabilla. ``` --- ### Cookies * Les cookies permettent de stocker des informations sur le navigateur de l'utilisateur. * Limité à une petite quantité (4ko) de texte * Ils sont stockés en clair sur la navigateur et facilement accessibles, recopiables, modifiables * On ne stocke donc dedans aucune donnée sensible ! * Ils ont une date d'expiration (par défaut à la fin de la session du navigateur) * On peut aussi y accéder en Javascript (sauf si on l'interdit explicitement) --- ### Cookies - Header HTTP Set-cookie * Si le serveur souhaite créer ou modifier des cookies, ils sont envoyés dans le header `Set-Cookie` de la réponse HTTP. ``` HTTP/2.0 200 OK Content-Type: text/html Set-Cookie: delicieux_cookie=choco Set-Cookie: savoureux_cookie=menthe [contenu de la page] ``` * Peut spécifier la date d'expiration (`Expires`), restreindre l'accès (`Secure`, `HttpOnly`), et définir où les cookies sont envoyés (`Domain`, `Path`, `SameSite`) ``` Set-Cookie: savoureux_cookie=menthe; Expires=Mon, 27 Mar 2050 07:28:00 GMT; Secure; HttpOnly ``` --- ### Cookies - Header HTTP Cookie * Pour toutes requêtes suivantes, le client envoie tous les cookies enregistrés (qui ont le droit d'être envoyés), dans le header `Cookie` de la requête HTTP. ``` GET /page_exemple.html HTTP/2.0 Host: www.example.org Cookie: delicieux_cookie=choco; savoureux_cookie=menthe ``` * Dans Rails, le module `ActionController#cookies` permet de lire et écrire les cookies HTTP. --- ### Cookies - ActionController#cookies - Écrire un cookie * Écrire un cookie basique ```ruby cookies[:user_name] = 'david' # On doit sérialiser "à la main" les données cookies[:lat_lon] = JSON.generate([47.68, -122.37]) ``` * Préciser une date d'expiration ```ruby cookies[:login] = { value: "XJ-122", expires: Time.utc(2020, 10, 15, 5) } cookies[:login] = { value: "XJ-122", expires: 1.hour } # Définir un cookie 'permanent', qui expire dans 20 ans cookies.permanent[:login] = "XJ-122" ``` --- ### Cookies - ActionController#cookies - Écrire un cookie (suite) * Sécuriser les cookies ```ruby # Définir un cookie signé, qui empêche la modification de la valeur du cookie cookies.signed[:user_id] = current_user.id # Définir un cookie chiffré, qui empêche la modification et la lecture de sa valeur cookies.encrypted[:discount] = 45 # Définir un cookie chiffré, qui expire dans 20 ans cookies.signed.permanent[:login] = 'XJ-122' ``` * Supprimer un cookie ```ruby cookies.delete :user_name ``` --- ### Cookies - ActionController#cookies * `cookies[:name]` permet de lire le cookie ```ruby cookies[:user_name] # => "david" cookies[:login] # => "XJ-122" # On doit sérialiser les données le cas échéant JSON.parse(cookies[:lat_lon]) # => [47.68, -122.37] # Lire des cookies sécurisés # encrypted et signed retournent nil si le cookie a été modifié cookies.encrypted[:discount] # => 45 cookies.signed[:login] # => "XJ-122" # Pour connaître le nombre de cookies cookies.size # => 2 ``` --- ### Sessions: HTTP : Protocole stateless * HTTP est un protocole sans état, c'est à dire que le serveur oubli le client entre chaque requête. * Chaque requête est indépendante, et tous les clients sont traités de la même manière. * Plus simple pour le serveur, mais peu rendre compliqué et lourd le mécanisme d'identification * Plutôt utilisé par les APIs * Un exemple d'authentification stateless: La `basic authentication` - On fournit un header spécifique * `Authorization: Basic ` * Où `` = base64(:) --- ### Sessions: HTTP : Authentification stateless ```ruby class ApplicationController < ActionController::Base before_action :authenticate_http_basic def authenticate_http_basic # Helper de rails pour parser le header Authorization en mode basic authenticate_with_http_basic do |username, password| # Gestion des passwords chiffrés user = User.find_by(username: username) @current_user = user.authenticate(password) if user end end end ``` --- ### Sessions : HTTP "stateful" * Permet d'ajouter du "stateful" à l'application, en fournissant une session par utilisateur pour y lire et y stocker des données. * Lors d'une requête, si le serveur ne reçoit pas d’identifiant de session, il va en créer un et va le transmettre dans sa réponse dans un cookie. * Le client va donc transmettre ensuite cet identifiant de session à chaque requête. * Par défaut Rails stocke toute la session (id et contenu) sur le client dans un cookie chiffré et ne nécessite aucune configuration. * On stockera en général peu de choses en session. (ce n'est pas utile et on est limité par la taille max du cookie) * On peut modifier ce comportement et stocker les sessions en cache, en base de donnée, ou via memcached (mais cela a ses inconvénients). --- ### Sessions : Accès * Écriture ```ruby session[:user_id] = 5 session[:last_activity] = Time.zone.now session[:active_filters] = { department: :all, groups: [:web1, :web2, :web3], } ``` * Lecture ```ruby current_user = User.find(session[:user_id]) session[:last_activity].class # => ActiveSupport::TimeWithZone session[:active_filters][:groups].include?(:web3) # => true ``` * Rails serialize automatiquement les données à la lecture et l'écriture --- ### Sessions : Reset * `reset_session` supprime la session actuelle et en créé une nouvelle vide ```ruby class UserSessionsController < ActionController::Base def logout reset_session @current_user = nil end end ``` --- ### Sessions : Authentification stateful - Login ```ruby class UserSessionsController < ApplicationController skip_before_action :check_login # slide suivante def create if params[:username] && params[:password] user = User.find_by(username: params[:username]) if user && user.authenticate(params[:password]) # La session est disponible partout session[:current_user] = user redirect_to root_url return end end redirect_to(login_url) end end ``` --- ### Sessions : Retrouver un utilisateur logué ```ruby class ApplicationController < ActionController::Base before_action :check_login def check_login if session[:current_user] @current_user = session[:current_user] else redirect_to(login_url) end end end ``` --- ![bg right:40% 40%](../shared/images/Ruby_On_Rails_Logo.svg) # Correction TP * Strong Params * CRUD * Enums * Migration en 2 (ou 3 temps) * Gérer les foreign keys * Filtrer des queries --- ### Strong Parameters * Permettent de valider des inputs * Rails auto-wrap les params dans une clef qui correspond à la resource du contrôleur Exemple: `CreaturesController`, rails wrappe tout dans `creature`. ```ruby class CreaturesController private def create_params permitted = params.require(:creature).permit(:name) permitted.require(:name) # rend le nom obligatoire permitted end end ``` On peut forcer ou autoriser la présence d'un paramètre. --- ### CRUD + L ```ruby class CreaturesController < ApplicationController # route: get '/creatures', to: 'creatures#index' def index # liste les créatures end # route: get '/creatures/:id', to: 'creatures#show' def show # affiche une créature end # route: post '/creatures', to: 'creatures#create' def create # créer une créature end # route: put '/creatures/:id', to: 'creatures#update' def update # mettre à jour une créature end # route: delete '/creatures', to: 'creatures#destroy' def destroy # supprimer une créature end end ``` --- ### Enumération * Concept ActiveRecord pour gérer des flags facilement, basé sur des colonnes `integer` ```ruby class AddSizeColumnToCreatures < ActiveRecord::Migration[7.0] def change add_column :creatures, :size, :integer end end ``` ```ruby class Creature < ApplicationRecord enum :size, [:small, :big, :giant] end ``` ```ruby creature = Creature.new(name: "Big Chungus", health_points: 42, size: :big) ``` --- ### Migrations en plusieurs phases Si on veut ajouter un nouveau champ en base de données : 1. On met à jour le schéma pour ajouter le champ (1 migration) 2. On met à jour la partie du code qui affecte le champ (1 update de code) 3. On met à jour les records de la base qui n'ont pas le champ (1 migration) 4. On met à jour le schéma pour forcer la présence du champ (1 migration optionnelle) 5. On affiche et on utilise le champ pour les raisons métier (# updates de code) Dans le TP : Ajout de `size` -> Ajout du callback pour setter la `size` -> Update des records en DB -> Affichage de la `size` --- ### Ajouter des Foreign Keys ActiveRecord vient avec le concept de référence qui simplifie. Il ajoute : * Le champ pour l'id (ex: `left_fighter_id`) * L'index pour le champ id (ex: `index_on_left_fighter_id`) * La Foreign Key pour le champ en question pour lier les tables * Si le champ référence ne s'appelle pas comme une table (ex: `creature_id`), il faut préciser la table. ```ruby create_table :combats do |t| # ... t.references :left_fighter, null: false, foreign_key: { to_table: 'creatures' } t.references :right_fighter, null: false, foreign_key: { to_table: 'creatures' } t.references :winner, null: true, foreign_key: { to_table: 'creatures' } end ``` --- ### Ajouter des Foreign Keys On oubliera pas d'ajouter les relations sur les modèles : ```ruby class Combat < ApplicationRecord enum :result, [:draw, :domination] belongs_to :left_fighter, class_name: 'Creature' belongs_to :right_fighter, class_name: 'Creature' # belongs_to rend la relation obligatoire, ici c'est facultatif belongs_to :winner, class_name: 'Creature', optional: true end ``` --- ### Utilisation des Foreign Keys On évitera globalement de manipuler des identifiants et on utilisera des records. ```ruby def baston! return if left_fighter.nil? || right_fighter.nil? left_hp = left_fighter.health_points right_hp = right_fighter.health_points left_fighter.health_points -= right_hp right_fighter.health_points -= right_hp if left_fighter.alive? self.winner = left_fighter self.result = :domination elsif right_fighter.alive? self.winner = right_fighter self.result = :domination else self.result = :draw end end ``` --- ### Ne pas oublier de sauvegarder les records liés * C'est différent de la création automatique des records liés ```ruby def create left_fighter = Creature.find(params[:left_fighter_id]) right_fighter = Creature.find(params[:right_fighter_id]) @combat = Combat.new(create_params) @combat.left_fighter = left_fighter @combat.right_fighter = right_fighter @combat.baston! # baston met à jour les créatures @combat.left_fighter.save! # il faut save pour persister la perte de pv @combat.right_fighter.save! @combat.save! render json: @combat.as_json(include: COMBATS_RENDER_CONFIG) rescue ActiveRecord::RecordNotFound render json: {}, status: 404 end ``` --- ### Filtrer des relations à la volée ```ruby def index @combats = Combat.all # on supporte uniquement les réels résultats # possibles qui sont listés sur le modèle Combat if Combat.results.include?(params[:result]) @combats = @combats.where(result: params[:result]) end # Si on fournit une query, on l'utilise pour la requête like if params.include?(:query) @combats = @combats.where("name LIKE ?", "%#{params[:query]}%") end render json: @combats.as_json(include: COMBATS_RENDER_CONFIG) end ```