Przejdź do treści
Intum Pomoc

Przykład: aplikacja Pogoda krok po kroku

Aktualizacja: 6 min czytania

Kompletny, sprawdzony przepis na aplikację wg wzorca “pobieraj z zewnętrznego API (ręcznie i cyklicznie), zapisuj każde pobranie do bazy, pokazuj historię w apce”. Składniki: custom connector (Open-Meteo), baza Noe, konektor zapisu, jeden custom flow wspólny dla crona i przycisku w apce, cykliczna akcja konta (cron) i aplikacja Noe (Svelte) z historią. Każdy rekord ma źródło: manual (kto i z jakiego IP kliknął) albo cron.

Autoryzacja: Authorization: Bearer TOKEN Content-Type: application/json; charset=utf-8

Krok 1 - custom connector do zewnętrznego API

POST /connect/connectors.json
{
  "connector": {
    "name": "Open-Meteo API (example1)",
    "code": "open-meteo-example1",
    "kind": "noe/custom_connector",
    "url": "https://api.open-meteo.com",
    "active": true,
    "methods": [
      {
        "method": "get_weather",
        "object_in": "json_object",
        "object_out": "json_object",
        "http_method": "GET",
        "path": "/v1/forecast?latitude={latitude}&longitude={longitude}&current_weather=true",
        "description": "Pobiera aktualną pogodę dla współrzędnych"
      }
    ]
  }
}

Pułapki:

  • methods to top-level pole konektora (kolumna jsonb), NIE fields.methods
  • bazowy url jest normalizowany do samego hosta - ścieżkę (np. /v1) podawaj w path metody
  • klucz API zewnętrznego serwisu (jeśli wymagany) idzie w secret_token (wysyłany jako Bearer)

Krok 2 - baza Noe na historię pobrań

POST /noe/dbs.json
{
  "db": {
    "name": "Pogoda - historia (example1)",
    "code": "weather-example1",
    "schema": {
      "fields": {
        "temperature": { "type": "number" },
        "windspeed": { "type": "number" },
        "alert": { "type": "string" },
        "source": { "type": "string" },
        "fetched_by": { "type": "string" },
        "ip": { "type": "string" }
      },
      "indexes": { "idx_str_1": "source" }
    },
    "public_methods": { "list": true }
  }
}
  • list: true - frontend czyta historię bez autoryzacji: POST /noe/db/weather-example1/list (rekordy {id, data, created_at}, najnowsze pierwsze; czas pobrania = created_at)
  • zapisy robi wyłącznie flow (niżej), więc publiczne create nie jest wystawiane

Krok 3 - konektor zapisu do bazy (prywatne API + token)

Program flow nie ma wbudowanej akcji “zapisz do bazy Noe” - zapis to krok connector na endpoint bazy. Utwórz token API (/account/api_tokens -> “Nowy”) i wstaw go w secret_token:

{
  "connector": {
    "name": "Zapis pogody do bazy Noe (example1)",
    "code": "weather-db-example1",
    "kind": "noe/custom_connector",
    "url": "http://intum1.intum.test",
    "secret_token": "TOKEN_API",
    "active": true,
    "methods": [
      { "method": "save_weather", "object_in": "json_object", "object_out": "json_object",
        "http_method": "POST", "path": "/noe/dbs/weather-example1/records.json",
        "description": "Zapisuje rekord pogody przez prywatne API rekordów (Bearer = secret_token)" }
    ]
  }
}

url = adres konta (na produkcji domena konta, na dev np. http://intum1.intum.test). Endpoint prywatnego API czyta z body record.data - nadmiarowe klucze obiektu ignoruje.

Krok 4 - JEDEN custom flow dla crona i przycisku

POST /connect/flows.json
{
  "flow": {
    "name": "Get Weather (example1)",
    "code": "get-weather-example1",
    "kind": "noe/custom_flow",
    "active": true,
    "fields": {
      "env": { "latitude": "52.23", "longitude": "21.01" },
      "program": [
        {
          "action": "mutation",
          "in": "json_object",
          "mutation": {
            "json_object.latitude": "{env.latitude}",
            "json_object.longitude": "{env.longitude}"
          },
          "out": "json_object"
        },
        {
          "action": "connector",
          "in": "json_object",
          "kind": "noe/custom_connector",
          "code": "open-meteo-example1",
          "method": "get_weather",
          "result_key": "weather",
          "out": "json_object"
        },
        {
          "action": "mutation",
          "in": "json_object",
          "mutation": {
            "json_object.record.data.temperature": "{json_object.weather.current_weather.temperature}",
            "json_object.record.data.windspeed": "{json_object.weather.current_weather.windspeed}"
          },
          "out": "json_object"
        },
        {
          "in": "json_object",
          "out": "json_object",
          "if": {
            "{json_object.record.data.temperature} > 30": {
              "then": [
                { "action": "mutation", "in": "json_object",
                  "mutation": { "json_object.record.data.alert": "'true'" }, "out": "json_object" }
              ],
              "else": [
                { "action": "mutation", "in": "json_object",
                  "mutation": { "json_object.record.data.alert": "'false'" }, "out": "json_object" }
              ]
            }
          }
        },
        {
          "in": "json_object",
          "out": "json_object",
          "if": {
            "{json_object.kind} == 'cron'": {
              "then": [
                { "action": "mutation", "in": "json_object",
                  "mutation": {
                    "json_object.record.data.source": "'cron'",
                    "json_object.record.data.fetched_by": "'cron'",
                    "json_object.record.data.ip": "'-'"
                  }, "out": "json_object" }
              ],
              "else": [
                { "action": "mutation", "in": "json_object",
                  "mutation": {
                    "json_object.record.data.source": "'manual'",
                    "json_object.record.data.fetched_by": "{json_object._user_name}",
                    "json_object.record.data.ip": "{json_object._ip}"
                  }, "out": "json_object" }
              ]
            }
          }
        },
        {
          "action": "connector",
          "in": "json_object",
          "kind": "noe/custom_connector",
          "code": "weather-db-example1",
          "method": "save_weather",
          "out": "json_object"
        }
      ]
    }
  }
}

Kluczowe elementy:

  • result_key: "weather" na kroku Open-Meteo - bez tego odpowiedź API ZASTĄPIŁABY cały obiekt i kontekst wywołania (kind, _user_name, _ip) przepadłby przed krokiem rozpoznającym źródło
  • skąd flow wie, kto go odpalił: cron przekazuje na wejściu { "kind": "cron", "cron_id": ... }, a akcja aplikacji Noe dokłada _action, _app_id i kontekst wywołującego: _user_name, _user_id, _ip - warunek {json_object.kind} == 'cron' rozróżnia źródła
  • współrzędne w fields.env - program czyta je jako {env.latitude}/{env.longitude} przy każdym uruchomieniu (cron i akcja); zmiana lokalizacji = edycja fields.env flow, bez dotykania programu (w apce robi to sekcja “Lokalizacja” PATCH-em na flow)
  • payload zapisu budowany pod zagnieżdżonym kluczem record.data (mutation umie tworzyć zagnieżdżone ścieżki) - dokładnie w formacie prywatnego API rekordów
  • konektory muszą istnieć PRZED flow (walidacja programu sprawdza metody)
  • test (dev): POST /connect/flows/get-weather-example1/start.json?perform_now=1 z body {}

Krok 5 - cykliczna akcja konta (cron)

POST /account/crons.json
{ "cron": { "kind": "flow", "target_code": "get-weather-example1", "schedule": "0 6 */3 * *", "name": "Pogoda - odświeżanie", "active": true } }

Ręczne odpalenie: POST /account/crons/:id/run.json albo “Uruchom teraz” w /account/crons.

Krok 6 - aplikacja Noe (Svelte) z historią pobrań

POST /noe/apps.json
{
  "app": {
    "name": "Pogoda (example1)",
    "url_code": "pogoda-example1",
    "description": "Przykładowa apka: custom connector + custom flow + cron + baza Noe z historią pobrań",
    "app_engine": "svelte",
    "css_engine": "tailwind_all",
    "public": true,
    "db_code": "weather-example1",
    "actions": [
      { "action": "get_weather", "flow_code": "get-weather-example1", "description": "Pobiera pogodę i zapisuje do bazy", "public": true }
    ],
    "source_code": "...kod Svelte (opis niżej)..."
  }
}

Frontend (Svelte 5): karta z gradientem pokazuje najnowszy pomiar (duża temperatura, wiatr, badge alertu), przycisk “Pobierz pogodę”, a pod nim historia pobrań - każdy wiersz z datą i czasem, temperaturą, wiatrem i badge źródła (fioletowy cron, niebieski ręcznie · kto (ip)).

Pod historią sekcja Lokalizacja (widoczna dla userów z dostępem do flows): dwa pola lat/lng, przycisk “Użyj mojej lokalizacji” (navigator.geolocation - wymaga HTTPS, na dev po HTTP przeglądarka może odmówić) oraz “Zapisz” - GET flow, merge fields.env i PATCH /connect/flows/<code>.json (z nagłówkiem X-CSRF-Token z meta). Domyślnie Warszawa; zmiana obowiązuje od następnego pobrania.

Przepływ po kliknięciu (frontend niczego nie zapisuje - zapis robi flow):

  1. POST /noe/apps/APP_ID/action/get_weather.json (POST akcji musi mieć .json)
  2. przy status: "pending" polling GET /noe/apps/APP_ID/action/get_weather/status?flow_process_id=...
  3. po zakończeniu przeładowuje listę POST /noe/db/weather-example1/list

Po utworzeniu appki:

  • podmień placeholder APP_ID w kodzie: PATCH /noe/apps/:id/replace.json { "field": "source_code", "old_string": "APP_ID", "new_string": "PRAWDZIWE_ID", "replace_all": true }
  • powiąż bazę z appką: PATCH /noe/dbs/weather-example1.json z { "db": { "app_id": "PRAWDZIWE_ID" } } - baza będzie widoczna w “Powiązaniach” na show appki

Pułapka przy wgrywaniu kodu przez curl: NIE wklejaj source_code w heredocu bez cudzysłowów - shell zje backticki i ${...} z template literals oraz runy $state/$derived. Zapisz kod do pliku i buduj JSON np. ruby/pythonem z odczytu pliku.

Krok 7 - weryfikacja

  • lokalnie musi działać worker SolidQueue (./solid_queue_local_sugester2) - akcje appki i cron wykonują flow asynchronicznie; bez workera przycisk wisi na “Pobieram…”, a joby czekają w kolejce
  • ręczne pobranie: klik w appce -> rekord source: manual z nazwą użytkownika i jego IP
  • cron: POST /account/crons/:id/run.json (albo czekaj na harmonogram) -> rekord source: cron
  • historia: POST /noe/db/weather-example1/list, widok appki /noe/pogoda-example1 albo rekordy bazy w /noe/dbs/weather-example1/records
  • po zmianie kodu appki zrób w przeglądarce twarde odświeżenie (stary bundle potrafi zostać w cache)
  • perform_now=1 wykonuje flow synchronicznie w środku requestu - krok zapisu strzela wtedy HTTP do tego samego serwera dev i może dostać timeout (408), mimo że rekord się zapisze; normalna ścieżka (worker) nie ma tego problemu
  • publiczny dostęp (public: true, bez logowania) działa dopiero na koncie zweryfikowanym - na niezweryfikowanym system blokuje publiczne appki (403, aktywność noe_app_blocked_unverified)
  • powiązania (appka -> baza/flow -> cron/konektory) widać na show appki w /noe/apps i na /connect/flows/:id

Powiązane