From cc49aafefef48178dd2aa2baa39794179c70f63f Mon Sep 17 00:00:00 2001 From: tim Date: Sun, 26 Mar 2023 23:05:32 +0200 Subject: [PATCH] Sem6 added --- cours/sem6/sem6.md | 712 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 712 insertions(+) create mode 100644 cours/sem6/sem6.md diff --git a/cours/sem6/sem6.md b/cours/sem6/sem6.md new file mode 100644 index 0000000..7bbec21 --- /dev/null +++ b/cours/sem6/sem6.md @@ -0,0 +1,712 @@ +--- +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 +``` \ No newline at end of file