You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
web-ruby/cours/sem6/sem6.md

712 lines
17 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

---
marp: true
theme: default
paginate: true
backgroundColor: #fff
footer: WEB : Ruby - Semaine 6 - Active Record / Sessions / Authentification
---
<style>
:root {
color: #455a64;
font-size: 28px;
align-items: left;
justify-content: left;
flex-direction: column;
padding-top: 1rem ;
}
h4 {
font-size: 1rem;
}
footer {
font-size: 0.8rem;
}
pre {
line-height: 130%;
}
</style>
<style scoped>
h1 {
color: red;
}
section {
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
}
</style>
![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| %>
<h1>
<%= message.title %>
<small><%= message.author.name %></small>
</h1>
<% 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| %>
<h1>
<%= message.title %>
<small><%= message.author.name %></small>
</h1>
<% 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 <credentials>`
*`<credentials>` = base64(<username>:<password>)
---
### 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 didentifiant 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
```
---
<style scoped>
h1 {
color: red;
}
section {
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
}
</style>
![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
```