PDM · DAW 2

API do curso · versão 1

API Tempo Açores

Uma API REST mantida pelo docente: as ilhas e os trilhos dos Açores, a previsão real do IPMA, e um feed de relatos da turma com texto e fotografia. É a base do mini-projeto 2.

Endereço basehttps://api-pdm.sublimecipher.com
Documentação interativahttps://api-pdm.sublimecipher.com/docs (Swagger: experimentas cada pedido no browser)
Especificação OpenAPIhttps://api-pdm.sublimecipher.com/openapi.json
FormatoJSON em UTF-8; datas em ISO 8601; números como números
AutenticaçãoJWT: Authorization: Bearer <token> em todos os pedidos menos o do token

Primeiros passos

  1. O teu clientId e o teu clientSecret estão numa issue no teu repositório pdm-miniprojeto. São só teus.
  2. Abre a documentação interativa. Em POST /v1/auth/token, carrega em Try it out, preenche o pedido e carrega em Execute.
  3. Copia o accessToken, carrega em Authorize no topo da página e cola-o. A partir daí todos os pedidos levam o token.
  4. Experimenta GET /v1/islands/sao-miguel/forecast: é a previsão de hoje, vinda do IPMA.

Autenticação

Trocas o clientId e o clientSecret por um token, e mandas o token em cada pedido. O token dura 1 hora; quando a API responder 401, pede outro.

POST /v1/auth/token
Content-Type: application/json

{
  "clientId": "pdm-ab12cd",
  "clientSecret": "o-teu-segredo"
}
{
  "accessToken": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
  "tokenType": "Bearer",
  "expiresIn": 3600
}
GET /v1/islands
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
Segurança O token é um JWT: três partes em base64 separadas por pontos, assinado pela API. Qualquer pessoa pode ler o que está lá dentro (cola-o em jwt.io), mas ninguém o consegue alterar sem a chave do servidor. Por isso não tem segredos dentro, só quem és e quando expira. O clientSecret, esse sim, não pode ir para o código que fazes commit: guarda-o fora do git, por exemplo com --dart-define.

Endpoints

Método e caminhoO que faz
POST /v1/auth/tokenTroca o clientId e o clientSecret por um token (sem token).
GET /v1/meQuem és, segundo o token. Bom para testar.
GET /v1/islandsAs nove ilhas.
GET /v1/islands/{id}Uma ilha.
GET /v1/islands/{id}/forecastPrevisão a 5 dias da ilha (IPMA).
GET /v1/trailsOs trilhos; ?islandId=faial filtra por ilha.
GET /v1/trails/{id}Um trilho.
GET /v1/reportsFeed de relatos da turma, mais recentes primeiro; ?islandId= e ?limit= (até 100).
POST /v1/reportsCria um relato: só texto (JSON) ou texto e fotografia (multipart).
GET /v1/reports/{id}Um relato.
PUT /v1/reports/{id}Altera o texto ou a ilha de um relato teu.
PUT /v1/reports/{id}/photoPõe ou substitui a fotografia de um relato teu (multipart).
DELETE /v1/reports/{id}Apaga um relato teu.

Ilhas e trilhos

{
  "id": "sao-miguel",
  "name": "São Miguel",
  "imageUrl": "https://api-pdm.sublimecipher.com/static/islands/sao_miguel.jpg",
  "trailCount": 3
}
{
  "id": "caldeira-faial",
  "name": "Volta à Caldeira do Faial",
  "islandId": "faial",
  "islandName": "Faial",
  "imageUrl": "https://api-pdm.sublimecipher.com/static/islands/faial.jpg",
  "distanceKm": 7.0,
  "duration": "2 h 30",
  "difficulty": "easy",
  "difficultyLabel": "Fácil",
  "description": "À volta da cratera com 2 km de diâmetro no centro da ilha."
}

Os ids são os mesmos da app das aulas: sao-miguel, santa-maria, terceira, graciosa, sao-jorge, pico, faial, flores, corvo. As fotografias das ilhas estão em imageUrl: usa Image.network em vez de Image.asset.

Previsão

{
  "islandId": "sao-miguel",
  "islandName": "São Miguel",
  "location": "Ponta Delgada",
  "source": "IPMA",
  "updatedAt": "2026-10-13T09:31:02",
  "days": [
    {
      "date": "2026-10-13",
      "weatherType": {
        "id": 2,
        "description": "Céu pouco nublado",
        "category": "partlyCloudy"
      },
      "tMin": 17.6,
      "tMax": 23.6,
      "rainChance": 0,
      "wind": {
        "direction": "SE",
        "class": 2,
        "description": "Moderado"
      }
    },
    "..."
  ]
}

Relatos

Um relato é um texto, e opcionalmente uma fotografia, sobre o tempo numa ilha. O feed é da turma toda: todos veem os relatos de todos, com o nome do autor, e cada um só altera e apaga os seus. O campo mine diz se o relato é teu.

{
  "id": "e7e97972-2db3-47d0-a817-6912481e64ad",
  "islandId": "faial",
  "islandName": "Faial",
  "text": "Céu limpo na Horta, mar calmo.",
  "photoUrl": "https://api-pdm.sublimecipher.com/media/be622df7-cfa6-45fb-bd71-c6cd92a763cf.jpg",
  "author": {
    "clientId": "pdm-ab12cd",
    "name": "Maria Silva"
  },
  "mine": true,
  "createdAt": "2026-10-13T09:59:43.115515Z",
  "updatedAt": "2026-10-13T09:59:43.115515Z"
}
POST /v1/reports
Authorization: Bearer ...
Content-Type: application/json

{
  "islandId": "faial",
  "text": "Céu limpo na Horta, mar calmo."
}
POST /v1/reports
Authorization: Bearer ...
Content-Type: multipart/form-data

islandId = faial
text     = Céu limpo na Horta, mar calmo.
photo    = (ficheiro JPEG, PNG ou WebP)
PUT /v1/reports/e7e97972-2db3-47d0-a817-6912481e64ad
Authorization: Bearer ...
Content-Type: application/json

{
  "text": "Afinal começou a chover às 17h."
}

Erros

Quando algo corre mal, o código HTTP diz o tipo de erro e o corpo explica-o em português:

{
  "error": {
    "code": "not_found",
    "message": "Não há nenhuma ilha com o id \"atlantida\"."
  }
}
CódigoQuandoO que a app deve fazer
400Pedido inválido: campo em falta, texto vazio ou longo, fotografia que não é imagemMostrar a message ao utilizador
401Sem token, token inválido ou expiradoPedir um token novo e repetir o pedido
403O relato não é teuNão mostrar editar e apagar nos relatos dos outros (mine)
404A ilha, o trilho ou o relato não existeMostrar "não encontrado"
413Fotografia com mais de 8 MBReduzir a fotografia antes de enviar
502O IPMA não respondeuMostrar um erro com "Tentar outra vez"

Em Dart

Com o pacote http (flutter pub add http). Os exemplos foram testados contra a API.

import 'dart:convert';
import 'package:http/http.dart' as http;

const base = 'https://api-pdm.sublimecipher.com';

Future<String> getToken(String clientId, String clientSecret) async {
  final res = await http.post(
    Uri.parse('$base/v1/auth/token'),
    headers: {'Content-Type': 'application/json'},
    body: jsonEncode({'clientId': clientId, 'clientSecret': clientSecret}),
  );
  if (res.statusCode != 200) {
    throw Exception(jsonDecode(res.body)['error']['message']);
  }
  return jsonDecode(res.body)['accessToken'];
}
final res = await http.get(
  Uri.parse('$base/v1/islands/faial/forecast'),
  headers: {'Authorization': 'Bearer $token'},
);
final days = jsonDecode(res.body)['days'] as List<dynamic>;
final req = http.MultipartRequest('POST', Uri.parse('$base/v1/reports'))
  ..headers['Authorization'] = 'Bearer $token'
  ..fields['islandId'] = 'faial'
  ..fields['text'] = 'Céu limpo na Horta, mar calmo.'
  ..files.add(await http.MultipartFile.fromPath('photo', file.path));
final res = await http.Response.fromStream(await req.send());
// 201: jsonDecode(res.body) é o relato criado, com photoUrl
final res = await http.put(
  Uri.parse('$base/v1/reports/$id'),
  headers: {'Authorization': 'Bearer $token', 'Content-Type': 'application/json'},
  body: jsonEncode({'text': 'Afinal começou a chover às 17h.'}),
);

No Chrome, MultipartFile.fromPath não funciona (não há sistema de ficheiros): usa MultipartFile.fromBytes('photo', bytes, filename: 'foto.jpg') com os bytes que o seletor de imagens devolve.

Regras de uso