Criar sistema web RESTful fullstack, com backend Django REST Framework - DRF (viewsets e routers) e frontend Vue.js TypeScript (vue router), com funções de CRUD (criar, listar, ver específico, editar e excluir) de cafés Parisienses. Testes através do Postman, estilização frontend através do Bootstrap (Bootstrap vue next). Possui os projetos "paris_cafe" (backend Django REST Framework) e "cafe_frontend" (frontend Vue.js TypeScript) integrados como sistema RESTful fullstack.
pip3 install django djangorestframework markdown django-filter django-cors-headers pytest pytest-django pytest-html model_bakery --break-system-packages
django-admin startproject paris_cafe
cd paris_cafe
python manage.py startapp cafes
INSTALLED_APPS = [
# Outro conteúdo
'rest_framework',
'corsheaders',
'cafes',
]
MIDDLEWARE = [
# Outro conteúdo
'corsheaders.middleware.CorsMiddleware',
]
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
],
'DEFAULT_PARSER_CLASSES': [
'rest_framework.parsers.JSONParser',
]
}
CORS_ALLOW_ALL_ORIGINS = True
# CORS_ALLOWED_ORIGINS = [
# "http://localhost:8080",
# ]
from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator
class Cafeteria(models.Model):
nome = models.CharField(max_length=255)
data_inauguracao = models.DateField()
endereco = models.CharField(max_length=255)
avaliacao = models.FloatField(validators=[MinValueValidator(0.0), MaxValueValidator(10.0)])
def __str__(self):
return f"{self.nome} ({self.avaliacao}/10)"
from rest_framework import serializers
from .models import Cafeteria
class CafeteriaSerializer(serializers.ModelSerializer):
class Meta:
model = Cafeteria
fields = '__all__'
from rest_framework import viewsets
from .models import Cafeteria
from .serializers import CafeteriaSerializer
class CafeteriaViewSet(viewsets.ModelViewSet):
queryset = Cafeteria.objects.all()
serializer_class = CafeteriaSerializer
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import CafeteriaViewSet
router = DefaultRouter()
router.register(r'cafes', CafeteriaViewSet)
urlpatterns = [
path('', include(router.urls)),
]
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('cafes.urls')),
]
python manage.py makemigrations
python manage.py migrate
python manage.py runserver
{
"nome": "Les Deux Magots",
"data_inauguracao": "1884-01-01",
"endereco": "6 Pl. Saint-Germain des Prés, 75006 Paris, França",
"avaliacao": 9.8
}
{
"nome": "Les Deux Magots ATUALIZADO",
"data_inauguracao": "1884-01-01",
"endereco": "6 Pl. Saint-Germain des Prés, 75006 Paris, França",
"avaliacao": 9.8
}
npm install -g @vue/cli
npm init vue@latest => Nome: cafe-frontend, Escolha: TypeScript, Router (SPA development)
cd cafe-frontend
npm install axios bootstrap bootstrap-vue-next --save-dev cypress ts-node @cypress/webpack-dev-server vitest @vitest/ui jsdom @vue/test-utils
<template>
<b-card class="mb-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5>{{ cafe.nome }}</h5>
<p class="mb-0">Avaliação: {{ cafe.avaliacao }}/10</p>
</div>
<div>
<router-link :to="`/view/${cafe.id}`" class="btn btn-sm btn-primary me-1">Ver</router-link>
<router-link :to="`/edit/${cafe.id}`" class="btn btn-sm btn-warning me-1">Editar</router-link>
<b-button size="sm" variant="danger" @click="$emit('delete', cafe.id)">Deletar</b-button>
</div>
</div>
</b-card>
</template>
<script setup lang="ts">
defineProps<{ cafe: any }>()
defineEmits(['delete'])
</script>
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import AddCafe from '../views/AddCafe.vue'
import EditCafe from '../views/EditCafe.vue'
import ViewCafe from '../views/ViewCafe.vue'
const routes = [
{ path: '/', name: 'Home', component: Home },
{ path: '/add', name: 'AddCafe', component: AddCafe },
{ path: '/edit/:id', name: 'EditCafe', component: EditCafe },
{ path: '/view/:id', name: 'ViewCafe', component: ViewCafe }
]
export default createRouter({
history: createWebHistory(),
routes
})
import axios from 'axios'
const api = axios.create({
baseURL: 'http://127.0.0.1:8000/',
})
export default api
<template>
<div class="container mt-4">
<h2>Adicionar Cafeteria:</h2>
<b-form @submit.prevent="addCafe">
<b-form-input v-model="form.nome" placeholder="Nome" name="nome" class="mb-2" maxlength="255" required />
<b-form-input v-model="form.data_inauguracao" type="date" name="data_inauguracao" class="mb-2" :max="new Date().toLocaleDateString('sv-SE')" required />
<b-form-textarea v-model="form.endereco" placeholder="Endereço" name="endereco" class="mb-2" maxlength="255" required />
<b-form-input v-model.number="form.avaliacao" type="number" step="0.01" min="0" max="10" name="avaliacao" class="mb-2"
required />
<b-button type="submit" variant="success" class="me-2">Confirmar</b-button>
<router-link to="/" class="btn btn-secondary">Cancelar</router-link>
</b-form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import api from '../services/api'
const router = useRouter()
const form = ref({ nome: '', data_inauguracao: '', endereco: '', avaliacao: 0 })
const addCafe = async () => {
await api.post('cafes/', form.value)
router.push('/')
}
</script>
<template>
<div class="container mt-4">
<h2>Editar Cafeteria {{ form.id }}:</h2>
<b-form @submit.prevent="updateCafe">
<b-form-input v-model="form.nome" name="nome" class="mb-2" maxlength="255" required />
<b-form-input v-model="form.data_inauguracao" type="date" name="data_inauguracao" class="mb-2" :max="new Date().toLocaleDateString('sv-SE')" required />
<b-form-textarea v-model="form.endereco" name="endereco" class="mb-2" maxlength="255" required />
<b-form-input v-model.number="form.avaliacao" type="number" step="0.01" min="0" max="10" name="avaliacao" class="mb-2"
required />
<b-button type="submit" variant="success" class="btn btn-success me-2">Confirmar</b-button>
<router-link to="/" class="btn btn-secondary">Cancelar</router-link>
</b-form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../services/api'
const route = useRoute()
const router = useRouter()
const form = ref<any>({})
onMounted(async () => {
const { data } = await api.get(`cafes/${route.params.id}/`)
form.value = data
})
const updateCafe = async () => {
await api.put(`cafes/${route.params.id}/`, form.value)
router.push('/')
}
</script>
<template>
<div class="container mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Cafés de Paris</h1>
<div>
<router-link to="/add" class="btn btn-success">Adicionar Cafeteria</router-link>
</div>
</div>
<button class="btn btn-sm btn-info me-2 mb-2" @click="toggleOrdenacao">
{{ ordenado ? 'Ordenação original' : 'Ordenar por avaliação' }}
</button>
<CafeItem v-for="cafe in cafes" :key="cafe.id" :cafe="cafe" @delete="deleteCafe" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import api from '../services/api'
import CafeItem from '../components/CafeItem.vue'
const cafes = ref<any[]>([])
const cafesOriginais = ref<any[]>([])
const ordenado = ref(false)
const fetchCafes = async () => {
const { data } = await api.get('cafes/')
cafesOriginais.value = [...data]
cafes.value = [...data]
}
const deleteCafe = async (id: number) => {
await api.delete(`cafes/${id}/`)
await fetchCafes()
if (ordenado.value) {
ordenarPorAvaliacao()
}
}
const ordenarPorAvaliacao = () => {
cafes.value = [...cafes.value].sort((a, b) => b.avaliacao - a.avaliacao)
}
const toggleOrdenacao = () => {
if (!ordenado.value) {
ordenarPorAvaliacao()
} else {
cafes.value = [...cafesOriginais.value]
}
ordenado.value = !ordenado.value
}
onMounted(fetchCafes)
</script>
<template>
<div class="container mt-4">
<h2 class="mb-3">Cafeteria {{ cafe?.id }}:</h2>
<p><strong>Nome:</strong> {{ cafe?.nome }}</p>
<p><strong>Inauguração:</strong> {{ cafe?.data_inauguracao.split('-').reverse().join('/') }}</p>
<p><strong>Endereço:</strong> {{ cafe?.endereco }}</p>
<p><strong>Avaliação:</strong> {{ cafe?.avaliacao }}/10</p>
<router-link to="/" class="btn btn-secondary mt-2">Voltar</router-link>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import api from '../services/api'
const cafe = ref<any>(null)
const route = useRoute()
onMounted(async () => {
const { data } = await api.get(`cafes/${route.params.id}/`)
cafe.value = data
})
</script>
<template>
<router-view />
</template>
<script setup lang="ts"></script>
<style></style>
import { createApp } from "vue";
import App from './App.vue';
import router from './router';
import * as BootstrapVueNext from 'bootstrap-vue-next';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-vue-next/dist/bootstrap-vue-next.css';
import {
BButton,
BForm,
BFormInput,
BFormTextarea,
BCard
} from 'bootstrap-vue-next';
const app = createApp(App);
app.use(router);
app.component('BButton', BButton);
app.component('BForm', BForm);
app.component('BFormInput', BFormInput);
app.component('BFormTextarea', BFormTextarea);
app.component('BCard', BCard);
app.mount('#app');
Testes unitários e de integração, via pytest.
[pytest]
DJANGO_SETTINGS_MODULE = paris_cafe.settings
python_files = tests.py test_*.py *_tests.py
import pytest
from model_bakery import baker
@pytest.fixture
def cafe():
return baker.make("cafes.Cafeteria") # 'cafes' é nome da App do projeto
import pytest
from cafes.models import Cafeteria
@pytest.mark.django_db
def test_cria_cafeteria():
cafe = Cafeteria.objects.create(
nome="Café do Centro",
data_inauguracao="2020-01-01",
endereco="Rua Principal, 123",
avaliacao=9.5,
)
assert cafe.nome == "Café do Centro"
assert cafe.avaliacao == 9.5
from cafes.serializers import CafeteriaSerializer
def test_cafe_serializer_valid_data():
data = {
"nome": "Café Central",
"data_inauguracao": "2021-01-01",
"endereco": "Av. Principal, 456",
"avaliacao": 8.7
}
serializer = CafeteriaSerializer(data=data)
assert serializer.is_valid()
assert serializer.validated_data["nome"] == "Café Central"
import pytest
from rest_framework.test import APIClient
from model_bakery import baker
@pytest.mark.django_db
def test_lista_cafeterias():
baker.make("cafes.Cafeteria", _quantity=3)
client = APIClient()
response = client.get("/cafes/")
assert response.status_code == 200
assert len(response.data) == 3
@pytest.mark.django_db
def test_cria_cafeteria_api():
client = APIClient()
data = {
"nome": "API Café",
"data_inauguracao": "2023-06-15",
"endereco": "Avenida API, 789",
"avaliacao": 7.2,
}
response = client.post("/cafes/", data=data, format='json')
assert response.status_code == 201
assert response.data["nome"] == "API Café"
Testes unitários e de integração, via test runner Vitest.
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/tests/setup.ts'],
},
})
"scripts": {
# Outro conteúdo
"test": "vitest",
"test:ui": "vitest --ui",
"test:e2e": "cypress run --config-file cypress.config.ts --env TS_NODE_COMPILER_OPTIONS='{\"module\":\"ESNext\"}'"
}
import { beforeAll } from 'vitest'
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => { },
removeListener: () => { },
addEventListener: () => { },
removeEventListener: () => { },
dispatchEvent: () => false,
}),
})
})
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
],
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": [
"cypress"
],
"esModuleInterop": true,
"moduleDetection": "force",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"strict": true
},
"include": [
"cypress/**/*.ts"
]
}
import { mount } from '@vue/test-utils'
import ViewCafe from '../../views/ViewCafe.vue'
import { describe, it, expect, vi } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import api from '@/services/api'
vi.mock('@/services/api')
const mockCafe = {
id: 1,
nome: 'Café de Flore',
data_inauguracao: '1887-01-01',
endereco: '172 Boulevard Saint-Germain, Paris',
avaliacao: 9.5,
}
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/view/:id', component: ViewCafe }],
})
describe('ViewCafe.vue', () => {
it('exibe detalhes da cafeteria', async () => {
(api.get as any).mockResolvedValue({ data: mockCafe })
router.push('/view/1')
await router.isReady()
const wrapper = mount(ViewCafe, {
global: {
plugins: [router],
},
})
await new Promise(resolve => setTimeout(resolve, 0))
expect(wrapper.text()).toContain('Café de Flore')
expect(wrapper.text()).toContain('1887')
expect(wrapper.text()).toContain('172 Boulevard Saint-Germain')
expect(wrapper.text()).toContain('9.5')
})
})
import { mount } from '@vue/test-utils'
import ViewCafe from '../../views/ViewCafe.vue'
import api from '@/services/api'
import { describe, it, expect, vi } from 'vitest'
const mockCafe = {
id: 1,
nome: 'Café Teste',
data_inauguracao: '2023-07-20',
endereco: 'Rua Teste, 123',
avaliacao: 8.5,
}
vi.mock('@/services/api', () => ({
default: {
get: vi.fn(() => Promise.resolve({ data: mockCafe })),
},
}))
vi.mock('vue-router', () => ({
useRoute: () => ({
params: { id: '1' },
}),
}))
describe('ViewCafe - teste de integração', () => {
it('exibe os detalhes da cafeteria corretamente', async () => {
const wrapper = mount(ViewCafe, {
global: {
stubs: ['router-link'],
},
})
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Café Teste')
expect(wrapper.text()).toContain('20/07/2023')
expect(wrapper.text()).toContain('Rua Teste, 123')
expect(wrapper.text()).toContain('8.5')
})
})
Testes End-to-End (E2E) para validar fluxo completo da aplicação RESTful fullstack, via Cypress.
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:5173',
specPattern: 'cypress/e2e/**/*.cy.{js,ts}',
supportFile: 'cypress/support/e2e.ts',
viewportWidth: 1280,
viewportHeight: 800,
defaultCommandTimeout: 8000,
video: true,
},
})
describe('Visualização de uma cafeteria', () => {
it('deve exibir os detalhes corretamente', () => {
cy.visit('http://localhost:5173/view/1')
cy.contains('Cafeteria 1:')
cy.contains('Nome:')
cy.contains('Avaliação:')
cy.contains('/10')
cy.get('a').contains('Voltar').click()
cy.url().should('eq', 'http://localhost:5173/')
})
})
Configuração de pipelines (fluxos automatizadores) de integração contínua (CI) e entrega contínua (CD) para automatizar testes e deploy no GitHub Actions. Criação de repositório GitHub para armazenar projetos backend (paris_cafe) e frontend (cafe-frontend). Conteúdo do repositório "prj_cafe_fullstack", contendo Pipelines backend.yml e frontend.yml:
Django
djangorestframework
markdown
django-filter
django-cors-headers
pytest
pytest-django
pytest-html
model_bakery
name: Django Backend CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
testes-backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
DJANGO_SETTINGS_MODULE: paris_cafe.settings
PYTHONUNBUFFERED: 1
steps:
- name: Checkout o código
uses: actions/checkout@v4
- name: Instalar Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Instalar dependências
working-directory: paris_cafe
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Migrar banco de dados
working-directory: paris_cafe
run: |
python manage.py migrate
- name: Executar testes com pytest
working-directory: paris_cafe
run: |
pytest --html=report.html
- name: Upload do relatório de testes
uses: actions/upload-artifact@v4
with:
name: pytest-report
path: paris_cafe/report.html
name: Pipeline Frontend CI
on:
push:
branches:
- main
- develop
pull_request:
jobs:
testes-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout do código
uses: actions/checkout@v3
- name: Instalar Node.js
uses: actions/setup-node@v3
with:
node-version: "22"
- name: Instalar dependências
run: npm install
working-directory: cafe-frontend
- name: Executar testes unitários e de integração
run: npx vitest
working-directory: cafe-frontend
- name: Gerar relatório de testes
run: npx vitest --reporter=html
working-directory: cafe-frontend
name: Pipeline E2E
on:
push:
branches:
- main
pull_request:
jobs:
testes-e2e:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Fazer checkout do código
uses: actions/checkout@v3
- name: Configurar Python 3.13
uses: actions/setup-python@v4
with:
python-version: "3.13"
- name: Configurar Node.js 22
uses: actions/setup-node@v3
with:
node-version: "22"
- name: Instalar dependências do backend
working-directory: ./paris_cafe
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Executar migrações Django
working-directory: ./paris_cafe
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
DJANGO_SETTINGS_MODULE: paris_cafe.settings
run: |
python manage.py migrate
- name: Instalar dependências frontend
working-directory: ./cafe-frontend
run: npm install
- name: Subir backend em background
working-directory: ./paris_cafe
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
DJANGO_SETTINGS_MODULE: paris_cafe.settings
run: |
nohup python manage.py runserver 0.0.0.0:8000 &
- name: Subir frontend em background
working-directory: ./cafe-frontend
run: |
nohup npm run dev &
- name: Executar testes E2E com Cypress
working-directory: ./cafe-frontend
env:
VITE_BACKEND_URL: http://127.0.0.1:8000
run: npx cypress run
Elaborado por Mateus Schwede
ubsocial.github.io