Nano framework PHP para desenvolvimento de API's e aplicações Web
Requisitos
- Composer
- PHP >= 8.4.0
- MySQL
Certifique-se de que as extensões abaixo, estejam habilitadas no php.ini
- extension=pdo_mysql
- extension=mbstring
- extension=curl
Instalação
composer require jonathansilva/nano
Configuração
Apache
RewriteEngine On
Options All -Indexes
RewriteCond %{SCRIPT_FILENAME} !-f
RewriteCond %{SCRIPT_FILENAME} !-d
RewriteRule ^(.*)$ index.php?uri=/$1 [L,QSA]
Nginx
location / {
if ($script_filename !~ "-f") {
rewrite ^(.*)$ /index.php?uri=/$1 break;
}
}
.env.example
DB_HOST=localhost
DB_USER=root
DB_PASS=password
DB_NAME=database
JWT_KEY=anything
JWT_EXP_IN_HOURS=8
COOKIE_EXP_IN_DAYS=1
COOKIE_DOMAIN=localhost
COOKIE_HTTPS=false
COOKIE_HTTPONLY=true
COOKIE_SAMESITE=Strict
CURL_SSL_VERIFYPEER=false
TEMPLATE_ENGINE_CACHE=false
Duplique o arquivo, renomeie para .env e altere os valores
.gitignore
.idea
.env
.vscode/
vendor/
cache/
composer.phar
composer.lock
index.php
<?php
require_once __DIR__ . '/vendor/autoload.php';
$app = Nano\Core\Router\Instance::create();
$app->use('App\Middleware\Token\Assert');
$app->notFound('App\Callback\Page\NotFound');
$app->get('/about', fn ($req, $res) => $res->view('about'));
$app->get('/hello/{name}', function ($req, $res) {
echo $req->params()->name;
});
$app->post('/api/test', function ($req, $res) {
$res->json(200, array('message' => 'It workerd!'));
});
$app->start();
Verbos: GET, POST, PUT, PATCH e DELETE
$app->get(
'/me', // Path
'App\Callback\Page\Me', // Callback
['App\Middleware\Token\Ensure'] // Middleware
);
O Callback/Controller não permite chamada de método ( exemplo: 'Namespace\Login@index' )
$app->get('/login', 'App\Callback\Page\Login');
Crie o método 'handle'
class Login
{
public function handle($req, $res)
{
// TODO
}
}
Para carregar um arquivo de rotas, utilize o método 'load'
$app->load(__DIR__ . '/src/routes.xml');
routes.xml
<?xml version="1.0" encoding="UTF-8"?>
<routes>
<route>
<path>/</path>
<method>GET</method>
<callback>App\Callback\Page\Home</callback>
</route>
<route>
<path>/me</path>
<method>GET</method>
<callback>App\Callback\Page\Me</callback>
<middlewares>
<middleware>App\Middleware\Token\Ensure</middleware>
</middlewares>
</route>
<route>
<path>/dashboard</path>
<method>GET</method>
<callback>App\Callback\Page\Dashboard</callback>
<middlewares>
<middleware>App\Middleware\Token\Ensure</middleware>
<middleware>App\Middleware\Role::admin</middleware>
</middlewares>
</route>
</routes>
Middlewares devem ser informados no terceiro parâmetro da rota ( Routes )
Para configurar um middleware global, utilize o método 'use'. É possível configurar quantos forem necessários
$app->use('App\Middleware\A');
$app->use('App\Middleware\B');
Veja abaixo alguns exemplos de middlewares
Middleware global que faz proteção contra CSRF e decodifica o payload do JWT
CSRF
Os formulários deverão ter um campo 'hidden' chamado 'csrf'
<input type="hidden" name="csrf" value="{{ $csrf }}">
O uso do CSRF necessita do
session_start();
no index.php
JWT
Se o token existir mas for inválido:
( Web ) Redireciona para a página 'login'
( API ) Retorna 'Invalid or expired token'
Caso for válido, o payload será enviado para o próximo middleware ou controller, podendo ser recuperado usando $req->query()
( veja um exemplo em Role Middleware )
Se não existir, vai para o próximo middleware ou executa o controller
<?php
namespace App\Middleware\Token;
use Nano\Core\Security\{ CSRF, JWT };
class Assert
{
public function handle($req, $res)
{
CSRF::assert($req, $res);
JWT::assert($req, $res, '/login');
}
}
Será chamado em rotas onde a autenticação é obrigatória
Se não encontrar o token:
( Web ) Redireciona para a página 'login'
( API ) Retorna 'Authorization token not found in request'
<?php
namespace App\Middleware\Token;
use Nano\Core\Security\JWT;
class Ensure
{
public function handle($req, $res)
{
JWT::ensure($req, $res, '/login');
}
}
A criação dos middlewares Assert e Ensure, obriga que as rotas de api tenham o prefixo '/api/', para evitar redirecionamento
O terceiro parâmetro em
JWT::assert
eJWT::ensure
, não deve ser informado se a autenticação for para API
Será chamado em rotas onde o usuário precisa ter níveis de acesso específicos
Coloque após o 'Ensure'
$app->get('/dashboard','App\Callback\Page\Dashboard', ['App\Middleware\Token\Ensure', 'App\Middleware\Token\Role::admin']);
<?php
namespace App\Middleware;
use App\Service\Auth\Role as Service;
use Exception;
class Role
{
public function handle($req, $res, $args)
{
try {
$id = $req->query()->data->id;
$role = new Service()->getRoleByUserId($id);
if (!in_array($role, $args)) {
$res->redirect('/me');
}
} catch (Exception $e) {
throw new Exception($e->getMessage());
}
}
}
localhost:8080/books?filter=price
<?php
namespace App\Callback\Page;
use App\Service\Book\Read as Service;
use Exception;
class Book
{
public function handle($req, $res)
{
try {
$filter = $req->query()->filter ?? null;
$data = new Service()->all($filter);
$res->view('book', array('books' => $data));
} catch (Exception $e) {
throw new Exception($e->getMessage());
}
}
}
Verbos: GET, POST, PUT, PATCH e DELETE
<?php
namespace App\Callback\Payment;
use Nano\Core\Error;
use Exception;
class Create
{
public function handle($req, $res)
{
try {
$headers = ['Content-Type: application/json'];
$body = json_encode([...]);
$data = $req->http()->post('https://...', $headers, $body);
if ($data) {
$info = json_decode($data);
$res->json($info->status, array('message' => $info->message));
}
throw new Exception('Erro ao realizar requisição');
} catch (Exception $e) {
Error::throwJsonException(500, $e->getMessage());
}
}
}
Regras: required, string, integer, float, bool, email, confirmed, min e max
Caso não houver erros na validação, um novo objeto será retornado em
$req->data()
com os dados 'sanitizados'
<?php
namespace App\Callback\Book;
use App\Service\Book\Create as Service;
use Nano\Core\Error;
use Exception;
class Create
{
public function handle($req, $res)
{
try {
$rules = [
'title' => 'required|string',
'description' => 'required|string|max:255',
'authors' => [
'name' => 'required|string',
'website' => 'string'
]
];
$req->validate($rules);
$data = $req->data();
$result = new Service()->register($data);
$res->json(201, array('message' => $result));
} catch (Exception $e) {
Error::throwJsonException(500, $e->getMessage());
}
}
}
$rules = [
'password' => 'required|string|min:8|confirmed'
];
O uso do confirmed
exige um novo input, onde o 'name' precisa ter o sufixo '_confirmation'
<div class="field">
<label for="password_confirmation">Confirmar senha</label>
<input type="password" id="password_confirmation" name="password_confirmation" spellcheck="false" autocomplete="off">
</div>
Por padrão, as mensagens de erro estão em português. As opções aceitas são 'pt-BR' e 'en-US'
$req->validate($rules, 'en-US');
Use throwJsonException
para exibir erros no formato json
<?php
namespace App\Callback\Book;
use Nano\Core\Error;
use Exception;
class Read
{
public function handle($req, $res)
{
try {
$res->json(200, array());
} catch (Exception $e) {
Error::throwJsonException(500, $e->getMessage());
}
}
}
O template utilizado foi desenvolvido por David Adams ( https://codeshack.io )
Foram feitas pequenas alterações no código original
base.html
<!DOCTYPE html>
<html lang="en-US">
<head>
<title>{% yield title %}</title>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/assets/style.css">
</head>
<body>
{# comentário de teste #}
<main>
{% yield content %}
</main>
</body>
</html>
home.html
{% extends base %}
{% block title %}Nano Framework{% endblock %}
{% block content %}
<h1>{{ $welcome }}</h1>
{% endblock %}
<?php
namespace App\Callback\Page;
class Home
{
public function handle($req, $res)
{
$res->view('home', array('welcome' => 'Welcome to Nano!'));
}
}
Crie o diretório 'views'
Exibindo os erros de validação ( Validator )
{% foreach ($errors as $value): %}
<div>{{ $value }}</div>
{% endforeach; %}
Para saber mais sobre este template engine, clique aqui
Coloque no index.php de sua API e modifique se necessário
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE');
header('Access-Control-Allow-Headers: Origin, Accept, Content-Type, Authorization, X-Requested-With');
Exemplo de login e cadastro de usuário
GET /cadastro
<?php
namespace App\Callback\Page;
use Nano\Core\View\Form;
class Register
{
public function handle($req, $res)
{
if ($req->hasCookie('token')) {
$res->redirect('/me');
}
$form = Form::session($req);
$res->view('register', [
'csrf' => $form->csrf,
'errors' => $form->errors
]);
}
}
POST /cadastro
Ao usar Cookie para salvar o JWT, nomeie-o de 'token'. Isso é necessário pois há funções na classe JWT que busca, verifica e remove o cookie, pelo nome 'token'
<?php
namespace App\Callback\User;
use App\Service\User\Create as Service;
use Nano\Core\Error;
use Exception;
class Create
{
public function handle($req, $res)
{
try {
$rules = [
'name' => 'required|string',
'email' => 'required|email|confirmed',
'password' => 'required|string|min:8|confirmed'
];
$req->validate($rules);
$data = $req->data();
$token = new Service()->register($data);
$req->setCookie('token', $token);
$res->redirect('/me');
} catch (Exception $e) {
$req->setSession('errors', Error::parse($e->getMessage()));
$res->redirect('/cadastro');
}
}
}
Service
<?php
namespace App\Service\User;
use Nano\Core\Database;
use Nano\Core\Security\JWT;
use Exception;
use PDO;
class Create
{
private PDO $db;
public function __construct()
{
$this->db = Database::instance();
}
public function register(object $data): string
{
$this->checkEmailExists($data->email);
$hash = password_hash($data->password, PASSWORD_ARGON2ID);
$query = "INSERT INTO users (name, email, password) VALUES (:name, :email, :password)";
$stmt = $this->db->prepare($query);
$stmt->bindValue(':name', $data->name, PDO::PARAM_STR);
$stmt->bindValue(':email', $data->email, PDO::PARAM_STR);
$stmt->bindValue(':password', $hash, PDO::PARAM_STR);
if (!$stmt->execute()) {
throw new Exception('Erro ao cadastrar, tente novamente');
}
$id = $this->db->lastInsertId();
$data = array('id' => $id); // Payload
$token = JWT::encode($data);
return $token;
}
private function checkEmailExists(string $email): bool
{
$query = "SELECT id FROM users WHERE email = :email";
$stmt = $this->db->prepare($query);
$stmt->bindValue(':email', $email, PDO::PARAM_STR);
$stmt->execute();
$result = $stmt->fetch();
if ($result > 0) {
throw new Exception('O e-mail informado, já existe');
}
return true;
}
}
GET /login
<?php
namespace App\Callback\Page;
use Nano\Core\View\Form;
class Login
{
public function handle($req, $res)
{
if ($req->hasCookie('token')) {
$res->redirect('/me');
}
$form = Form::session($req);
$res->view('login', [
'csrf' => $form->csrf,
'errors' => $form->errors
]);
}
}
POST /login
<?php
namespace App\Callback\Auth;
use App\Service\Auth\Login as Service;
use Nano\Core\Error;
use Exception;
class Login
{
public function handle($req, $res)
{
try {
$rules = [
'email' => 'required|email',
'password' => 'required|string'
];
$req->validate($rules);
$data = $req->data();
$token = new Service()->authenticate($data);
$req->setCookie('token', $token);
$res->redirect('/me');
} catch (Exception $e) {
$req->setSession('errors', Error::parse($e->getMessage()));
$res->redirect('/login');
}
}
}
Service
<?php
namespace App\Service\Auth;
use Nano\Core\Database;
use Nano\Core\Security\JWT;
use Exception;
use PDO;
class Login
{
private PDO $db;
public function __construct()
{
$this->db = Database::instance();
}
public function authenticate(object $data): string
{
$query = "SELECT id, password FROM users WHERE email = :email";
$stmt = $this->db->prepare($query);
$stmt->bindValue(':email', $data->email, PDO::PARAM_STR);
$stmt->execute();
$result = $stmt->fetchObject();
if (!password_verify($data->password, $result->password)) {
throw new Exception('E-mail ou senha inválido');
}
$data = array('id' => $result->id); // Payload
$token = JWT::encode($data);
return $token;
}
}
GET /logout
<?php
namespace App\Callback\Auth;
class Logout
{
public function handle($req, $res)
{
if ($req->hasCookie('token')) {
$req->removeCookie('token');
$res->redirect('/login');
}
$res->redirect('/');
}
}
GET /me
<?php
namespace App\Callback\Page;
use App\Service\User\Read as Service;
use Exception;
class Me
{
public function handle($req, $res)
{
try {
$id = $req->query()->data->id;
$data = new Service()->getUserInfoById($id);
$res->view('me', $data);
} catch (Exception $e) {
throw new Exception($e->getMessage());
}
}
}