{
  "openapi": "3.1.0",
  "info": {
    "title": "Mapa Oświatowa — publiczne API",
    "version": "2026-09-03",
    "summary": "Dane o ponad 59 000 polskich placówek oświatowych: żłobkach, przedszkolach, szkołach i uczelniach.",
    "description": "Publiczne API serwisu Mapa Oświatowa. Wszystkie opisane niżej endpointy to `GET`, bez uwierzytelniania i bez klucza, odpowiedzi w JSON (UTF-8).\n\nZasady, które obowiązują na każdym endpoincie:\n\n- **Limit 120 zapytań na minutę z jednego adresu IP.** Po przekroczeniu API odpowiada 429 z nagłówkiem `Retry-After`.\n- **Parametry z polskimi znakami trzeba zakodować** (`Czyżowice` → `Czy%C5%BCowice`).\n- **`null` znaczy „nie zgłoszono”, nigdy zero.** Dotyczy to danych SIO, szeregów GUS i pól rejestrowych — braku nie wolno interpretować jako wartości zerowej.\n- **Dane kontaktowe** (`phone`, `mobile`, `fax`, `email` i profile społecznościowe) zwraca wyłącznie `GET /api/institutions/{id}`, po jednej placówce na zapytanie. Listy ich nie zawierają, żeby nie służyły do masowego pobierania kontaktów.\n- **`description` też zwraca tylko `GET /api/institutions/{id}`** — nie ze względu na dane, a na wagę: to jedyne pole z dowolnym tekstem obok samych enumów i liczb, więc na liście waży więcej niż cała reszta wiersza.\n- **Zespół szkół nie jest sumowany ze swoimi szkołami.** W SIO uczniowie są wykazywani przy szkołach członkowskich, a nauczyciele przy zespole.\n\nPełny opis pól, słowników i reguł interpretacji danych: https://mapaoswiatowa.pl/dla-deweloperow oraz https://mapaoswiatowa.pl/llms.txt.\n\nSchematy odpowiedzi opisane są słownie — API nie gwarantuje zamkniętej listy pól w obiekcie i może dodawać nowe. Zamknięte są natomiast słowniki parametrów (`type`, `subtype`, `operatorType`, `religion`, `pedagogy`), wyliczone w `components/schemas`.",
    "termsOfService": "https://mapaoswiatowa.pl/regulamin",
    "contact": {
      "name": "Mapa Oświatowa",
      "url": "https://mapaoswiatowa.pl/kontakt",
      "email": "kontakt@pawlicaweb.pl"
    },
    "license": {
      "name": "Dane z rejestrów publicznych — licencje mieszane (ODbL 1.0, CC BY 4.0, CC0)",
      "url": "https://mapaoswiatowa.pl/dla-deweloperow#licencje"
    }
  },
  "servers": [
    {
      "url": "https://api.mapaoswiatowa.pl",
      "description": "Produkcja"
    }
  ],
  "externalDocs": {
    "description": "Dokumentacja API dla deweloperów",
    "url": "https://mapaoswiatowa.pl/dla-deweloperow"
  },
  "tags": [
    { "name": "Placówki", "description": "Wyszukiwanie i profile placówek oświatowych" },
    { "name": "Wyszukiwanie", "description": "Zamiana nazwy miejscowości na współrzędne i odwrotnie" },
    { "name": "Miejscowości", "description": "Miejscowości z liczbą placówek, statystyki i dane GUS" },
    { "name": "Podziały administracyjne", "description": "Województwa, powiaty i gminy" },
    { "name": "Statystyki", "description": "Agregaty krajowe, opłaty, trendy zawodów, prognoza demograficzna" },
    { "name": "Patroni", "description": "Patroni szkół" },
    { "name": "Kształcenie zawodowe", "description": "Zawody nauczane w technikach i szkołach branżowych" },
    { "name": "Status", "description": "Stan usługi" }
  ],
  "paths": {
    "/health": {
      "get": {
        "tags": ["Status"],
        "summary": "Stan usługi",
        "description": "Sprawdza dostępność API i jego bazy danych. Endpoint statusowy wskazywany relacją `status` w katalogu API (`/.well-known/api-catalog`).",
        "operationId": "getHealth",
        "responses": {
          "200": {
            "description": "Usługa działa: `{ \"status\": \"ok\" }`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": { "status": { "type": "string", "const": "ok" } }
                }
              }
            }
          },
          "503": {
            "description": "Baza danych nieosiągalna: `{ \"status\": \"error\", \"db\": \"disconnected\" }`.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          }
        }
      }
    },
    "/api/locations/search": {
      "get": {
        "tags": ["Wyszukiwanie"],
        "summary": "Wyszukiwarka miejscowości, dzielnic, placówek i kodów pocztowych",
        "description": "Jedyny sposób zamiany nazwy miejscowości na współrzędne — API nie geokoduje nazw w żadnym innym endpoincie. Dopasowanie po początku nazwy.\n\nZwraca `results[]` z wpisami typu `city` (pola `id` — to `cityId` do filtrowania placówek, `label`, `slug`, `voivodeship`, `count`, `lat`, `lng`), `district`, `institution` oraz `postalCode`, gdy zapytanie zaczyna się od cyfry.",
        "operationId": "searchLocations",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "Fraza, minimum 2 znaki, maksimum 100.",
            "schema": { "type": "string", "minLength": 2, "maxLength": 100 },
            "example": "Czyżowice"
          }
        ],
        "responses": {
          "200": {
            "description": "Obiekt z tablicą `results[]`.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "400": { "$ref": "#/components/responses/BadQuery" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/locations/reverse": {
      "get": {
        "tags": ["Wyszukiwanie"],
        "summary": "Najbliższa miejscowość dla współrzędnych",
        "operationId": "reverseGeocode",
        "parameters": [
          { "$ref": "#/components/parameters/Lat" },
          { "$ref": "#/components/parameters/Lng" }
        ],
        "responses": {
          "200": {
            "description": "Najbliższa miejscowość wraz z jej slugiem i województwem.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "400": { "$ref": "#/components/responses/BadQuery" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/institutions": {
      "get": {
        "tags": ["Placówki"],
        "summary": "Paginowana lista placówek",
        "description": "Lista placówek z filtrami, sortowaniem i stronicowaniem. **Bez danych kontaktowych i bez `description`** — te zwraca wyłącznie `GET /api/institutions/{id}`.\n\nOdpowiedź: `{ institutions: [...], pagination: { page, limit, total, pages } }`.\n\nGdy nazwa miejscowości powtarza się w Polsce (np. Czyżowice w śląskim i w lubelskim), filtruj po `cityId` z `/api/locations/search`, nie po `city`.\n\nParametry `powiat` i `gmina` podawaj razem z nadrzędnymi: slug powiatu jest unikalny tylko w województwie, a slug gminy tylko w powiecie.\n\nWartość spoza zamkniętego słownika w `religion`, `operatorType` i `pedagogy` nie zwraca błędu — filtr jest po cichu pomijany (pokazanie zbyt wielu wyników degraduje lepiej niż błąd całego zapytania).",
        "operationId": "listInstitutions",
        "parameters": [
          { "$ref": "#/components/parameters/Page" },
          { "$ref": "#/components/parameters/Limit" },
          {
            "name": "type",
            "in": "query",
            "description": "Typ placówki.",
            "schema": { "$ref": "#/components/schemas/InstitutionType" }
          },
          {
            "name": "excludeType",
            "in": "query",
            "description": "Typ placówki wykluczony z wyników.",
            "schema": { "$ref": "#/components/schemas/InstitutionType" }
          },
          {
            "name": "subtype",
            "in": "query",
            "description": "Podtyp placówki.",
            "schema": { "$ref": "#/components/schemas/InstitutionSubtype" }
          },
          { "$ref": "#/components/parameters/VoivodeshipQuery" },
          {
            "name": "powiat",
            "in": "query",
            "description": "Slug powiatu; podawaj razem z `voivodeship`.",
            "schema": { "type": "string" }
          },
          {
            "name": "gmina",
            "in": "query",
            "description": "Slug gminy; podawaj razem z `voivodeship` i `powiat`.",
            "schema": { "type": "string" }
          },
          {
            "name": "city",
            "in": "query",
            "description": "Nazwa miejscowości, dopasowanie zawierające. Niejednoznaczna dla nazw powtarzających się w kraju — wtedy użyj `cityId`.",
            "schema": { "type": "string" }
          },
          {
            "name": "cityId",
            "in": "query",
            "description": "Identyfikator miejscowości z `/api/locations/search` (pole `id` wyniku typu `city`). Dopasowanie dokładne — zalecane.",
            "schema": { "type": "string" }
          },
          {
            "name": "search",
            "in": "query",
            "description": "Fraza w nazwie placówki.",
            "schema": { "type": "string" }
          },
          {
            "name": "isPublic",
            "in": "query",
            "description": "Placówka publiczna (`true`) albo niepubliczna (`false`). To pole opisuje sektor — nie mylić z `operatorType`, które mówi, kto placówkę prowadzi.",
            "schema": { "type": "boolean" }
          },
          {
            "name": "wheelchairAccessible",
            "in": "query",
            "description": "Dostępność dla wózków.",
            "schema": { "type": "boolean" }
          },
          {
            "name": "fee",
            "in": "query",
            "description": "Placówka pobiera opłatę.",
            "schema": { "type": "boolean" }
          },
          {
            "name": "canteen",
            "in": "query",
            "description": "Stołówka.",
            "schema": { "type": "boolean" }
          },
          {
            "name": "hasBoarding",
            "in": "query",
            "description": "Internat lub bursa.",
            "schema": { "type": "boolean" }
          },
          {
            "name": "religion",
            "in": "query",
            "description": "Wyznanie placówki wyznaniowej. Brak wartości znaczy „placówka nie jest wyznaniowa”, nie „świecka”.",
            "schema": { "$ref": "#/components/schemas/Religion" }
          },
          {
            "name": "operatorType",
            "in": "query",
            "description": "Organ prowadzący — kto prowadzi placówkę. Wypełnione dla ~59% placówek; `null` znaczy „rejestr nie podaje”, nigdy „prowadzi ją gmina”.",
            "schema": { "$ref": "#/components/schemas/OperatorType" }
          },
          {
            "name": "pedagogy",
            "in": "query",
            "description": "Pedagogika alternatywna. Szkoła leśna nie jest tu wartością — to `forest` w `schoolTypes`.",
            "schema": { "$ref": "#/components/schemas/Pedagogy" }
          },
          {
            "name": "schoolType",
            "in": "query",
            "description": "Charakter szkoły z `schoolTypes` (np. `forest`, `sports`, `integration`).",
            "schema": { "type": "string" }
          },
          {
            "name": "employsSpeechTherapist",
            "in": "query",
            "description": "Zatrudnia logopedę (dane RSPO, wypełnione w 100% w obrębie rejestru).",
            "schema": { "type": "boolean" }
          },
          {
            "name": "employsPsychologist",
            "in": "query",
            "description": "Zatrudnia psychologa (dane RSPO).",
            "schema": { "type": "boolean" }
          },
          {
            "name": "employsPedagogue",
            "in": "query",
            "description": "Zatrudnia pedagoga (dane RSPO).",
            "schema": { "type": "boolean" }
          },
          {
            "name": "language",
            "in": "query",
            "description": "Jedna nazwa języka obcego nauczanego w placówce, po polsku.",
            "schema": { "type": "string" },
            "example": "niemiecki"
          },
          {
            "name": "higherEducationProfile",
            "in": "query",
            "description": "Profil uczelni wyższej.",
            "schema": { "$ref": "#/components/schemas/HigherEducationProfile" }
          },
          {
            "name": "supervisoryBody",
            "in": "query",
            "description": "Organ sprawujący nadzór pedagogiczny.",
            "schema": { "type": "string" }
          },
          {
            "name": "patron",
            "in": "query",
            "description": "Patron placówki (znormalizowana nazwa, jak w `/api/patrons/top`).",
            "schema": { "type": "string" }
          },
          {
            "name": "minYear",
            "in": "query",
            "description": "Najwcześniejszy rok rozpoczęcia działalności.",
            "schema": { "type": "integer", "minimum": 1800, "maximum": 2100 }
          },
          {
            "name": "maxYear",
            "in": "query",
            "description": "Najpóźniejszy rok rozpoczęcia działalności.",
            "schema": { "type": "integer", "minimum": 1800, "maximum": 2100 }
          },
          {
            "name": "minMonthlyFee",
            "in": "query",
            "description": "Minimalna opłata miesięczna w złotych.",
            "schema": { "type": "number", "minimum": 0 }
          },
          {
            "name": "maxMonthlyFee",
            "in": "query",
            "description": "Maksymalna opłata miesięczna w złotych.",
            "schema": { "type": "number", "minimum": 0 }
          },
          {
            "name": "minLat",
            "in": "query",
            "description": "Dolna granica prostokąta wyszukiwania. Wszystkie cztery granice podawaj razem.",
            "schema": { "type": "number" }
          },
          {
            "name": "maxLat",
            "in": "query",
            "description": "Górna granica prostokąta wyszukiwania.",
            "schema": { "type": "number" }
          },
          {
            "name": "minLng",
            "in": "query",
            "description": "Zachodnia granica prostokąta wyszukiwania.",
            "schema": { "type": "number" }
          },
          {
            "name": "maxLng",
            "in": "query",
            "description": "Wschodnia granica prostokąta wyszukiwania.",
            "schema": { "type": "number" }
          },
          {
            "name": "sortBy",
            "in": "query",
            "description": "Porządek wyników.",
            "schema": {
              "type": "string",
              "enum": ["name", "views", "monthlyFee"],
              "default": "name"
            }
          },
          {
            "name": "promoted",
            "in": "query",
            "description": "Tylko wpisy promowane (płatne). Nie mieszaj ich z wynikami merytorycznymi.",
            "schema": { "type": "string", "enum": ["true"] }
          }
        ],
        "responses": {
          "200": {
            "description": "Obiekt z `institutions[]` i `pagination`.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "institutions": { "type": "array", "items": { "type": "object" } },
                    "pagination": { "$ref": "#/components/schemas/Pagination" }
                  }
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadQuery" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/institutions/nearby": {
      "get": {
        "tags": ["Placówki"],
        "summary": "Placówki w promieniu od punktu",
        "description": "Placówki posortowane rosnąco po odległości, z polem `distance` w kilometrach. Bez danych kontaktowych i bez `description`.\n\nOdpowiedź: `{ institutions: [...], promoted: [...] }` — `promoted` to wpisy płatne w promieniu 50 km, wyłączone z `institutions`; nie mieszaj ich z wynikami merytorycznymi.\n\nWiersze niosą co najwyżej `sioPupils` i `sioSchoolYear`; pełny obiekt `sioStats` zwraca tylko `GET /api/institutions/{id}`.",
        "operationId": "listNearbyInstitutions",
        "parameters": [
          { "$ref": "#/components/parameters/Lat" },
          { "$ref": "#/components/parameters/Lng" },
          {
            "name": "radius",
            "in": "query",
            "description": "Promień w kilometrach.",
            "schema": { "type": "number", "minimum": 1, "maximum": 100, "default": 20 }
          },
          {
            "name": "type",
            "in": "query",
            "description": "Typy placówek rozdzielone przecinkami — tu, w odróżnieniu od `/api/institutions`, przyjmowana jest lista.",
            "schema": { "type": "string" },
            "example": "NURSERY,PRESCHOOL"
          },
          {
            "name": "subtype",
            "in": "query",
            "description": "Podtyp placówki.",
            "schema": { "$ref": "#/components/schemas/InstitutionSubtype" }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Liczba wyników.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 25 }
          }
        ],
        "responses": {
          "200": {
            "description": "Obiekt z `institutions[]` (z polem `distance`) i `promoted[]`.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "400": { "$ref": "#/components/responses/BadQuery" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/institutions/popular": {
      "get": {
        "tags": ["Placówki"],
        "summary": "Najczęściej oglądane placówki",
        "description": "Dzienna próbka placówek z opisem i niezerową liczbą odsłon. Zestaw zmienia się raz na dobę.",
        "operationId": "listPopularInstitutions",
        "responses": {
          "200": {
            "description": "Obiekt z `institutions[]`.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/institutions/{id}": {
      "get": {
        "tags": ["Placówki"],
        "summary": "Pełny profil placówki",
        "description": "Ponad 50 pól: adres, kontakt, opłaty, zdjęcia z atrybucją, zawody w technikach, statystyki SIO (`sioStats`, `sioAvailability`), placówki podrzędne zespołu (`childInstitutions`), a dla placówki członkowskiej — jej zespół (`parentInstitution`) i pozostałe placówki tego zespołu (`siblingInstitutions`).\n\nJako `{id}` zadziała numeryczne `id`, `slug`, numer RSPO w formacie slugu oraz `nurseryRegistryId` żłobka (np. `9199/Z`, zakodowane jako `9199%2FZ`). **Sama liczba jest czytana jako `id`, nie jako numer RSPO** — zakresy obu się pokrywają, więc żeby zapytać o numer RSPO, dodaj `?by=rspo`.\n\nJeśli placówka została znaleziona pod starym adresem, odpowiedź zawiera `redirectSlug` z aktualnym slugiem.\n\n`staticMapUrl` to gotowy adres miniatury mapy na CDN — może być `null` i nie gwarantuje, że render już istnieje.",
        "operationId": "getInstitution",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "`id`, `slug`, numer RSPO w formacie slugu albo `nurseryRegistryId`.",
            "schema": { "type": "string" },
            "example": "przedszkole-nr-1-w-czyzowicach"
          },
          {
            "name": "by",
            "in": "query",
            "description": "`rspo` każe czytać `{id}` jako numer RSPO. Inna wartość to HTTP 400.",
            "schema": { "type": "string", "enum": ["rspo"] }
          }
        ],
        "responses": {
          "200": {
            "description": "Profil placówki.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "400": { "$ref": "#/components/responses/BadQuery" },
          "410": {
            "description": "Placówka usunięta z rejestru — jej adres nie zostanie ponownie użyty.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/cities": {
      "get": {
        "tags": ["Miejscowości"],
        "summary": "Miejscowości z liczbą placówek",
        "description": "Miejscowości posortowane malejąco po liczbie placówek. Wiersze niosą `countySlug` i `countyIsCity` (miasto na prawach powiatu — jego URL powiatowy tylko przekierowuje).",
        "operationId": "listCities",
        "parameters": [
          { "$ref": "#/components/parameters/VoivodeshipQuery" },
          {
            "name": "limit",
            "in": "query",
            "description": "Liczba miejscowości.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 5000 }
          },
          {
            "name": "fields",
            "in": "query",
            "description": "`list` zwraca węższy zestaw pól (bez danych powiatu) — lżejsza odpowiedź dla list.",
            "schema": { "type": "string", "enum": ["list"] }
          }
        ],
        "responses": {
          "200": {
            "description": "Tablica miejscowości z liczbą placówek.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "400": { "$ref": "#/components/responses/BadQuery" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/cities/{slug}": {
      "get": {
        "tags": ["Miejscowości"],
        "summary": "Szczegóły miejscowości",
        "description": "Statystyki miejscowości, rozbicie na typy placówek i dane zewnętrzne:\n\n- `lat`/`lng` — **środek miejscowości z PRNG** (punkt główny wsi lub miasta, dokładność ~30 m), nie średnia współrzędnych placówek i nie współrzędna żadnej placówki.\n- `nameGenitive`, `nameAdjective` — odmiana nazwy z PRNG.\n- `rmCode` — rodzaj miejscowości wprost z rejestru TERYT SIMC (`01` wieś, `96` miasto, `99` część miasta itd.); `null`, gdy nie udało się dopasować.\n- `population` — ludność wg NSP 2021 (stan na 31.03.2021); `null`, gdy GUS nie publikuje danych dla tej miejscowości.\n- `educationStats[]` — szeregi GUS 2008–2024; `null` w kolumnie to „brak informacji”, nigdy zero ani brak szkoły.\n- `educationBenchmarks` — średnia liczebność oddziału na tle województwa i kraju, liczona z oficjalnych szeregów GUS BDL, nigdy z sumowania miejscowości.\n- `sioStats` — agregaty SIO za najnowszy rok szkolny. To inne uniwersum pomiaru niż `educationStats` — obu nie należy zestawiać jako sprzeczności.\n- `siblingLocalities[]` — pozostałe miejscowości tej samej gminy.",
        "operationId": "getCity",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "Slug miejscowości (pole `citySlug` placówki).",
            "schema": { "type": "string" },
            "example": "czyzowice"
          }
        ],
        "responses": {
          "200": {
            "description": "Szczegóły miejscowości.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/divisions/{woj}": {
      "get": {
        "tags": ["Podziały administracyjne"],
        "summary": "Powiaty województwa",
        "description": "Lista powiatów: `slug`, `name` (nazwa rejestrowa — dla powiatów ziemskich przymiotnik małą literą), `terytCode` (4 cyfry TERC), `isCityCounty`, `citySlug`, `institutionCount`, `municipalityCount`, `localityCount`.\n\nLiczby miejscowości dotyczą wyłącznie miejscowości mających placówki w tym serwisie (~19% pełnego rejestru SIMC), nigdy wszystkich miejscowości jednostki.",
        "operationId": "listCounties",
        "parameters": [{ "$ref": "#/components/parameters/VoivodeshipPath" }],
        "responses": {
          "200": {
            "description": "Powiaty województwa.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/divisions/{woj}/{powiat}": {
      "get": {
        "tags": ["Podziały administracyjne"],
        "summary": "Szczegóły powiatu",
        "description": "`totals` (placówki wg typu), `operatorMix` (organy prowadzące; `operatorType: null` to „rejestr nie podaje”), `municipalities[]` z rozbiciem na miejscowości, `nearbyCounties[]`, `educationStats[]` i `educationBenchmarks` (oficjalne serie GUS BDL), `teacherStats[]` (nauczyciele wg stopnia awansu z SIO 2020–2025; stopnie sprzed i po reformie 2022 to różne systemy), `lat`/`lng` (średnia centroidów PRNG — wyłącznie do centrowania mapy).\n\n**Miasto na prawach powiatu zwraca `{ \"redirect\": { \"slug\": … } }`** — jego strona to `/miasta/{slug}`. Powiat pytany pod złym województwem to 404.",
        "operationId": "getCounty",
        "parameters": [
          { "$ref": "#/components/parameters/VoivodeshipPath" },
          { "$ref": "#/components/parameters/CountyPath" }
        ],
        "responses": {
          "200": {
            "description": "Szczegóły powiatu albo obiekt `redirect` dla miasta na prawach powiatu.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/divisions/{woj}/{powiat}/{gmina}": {
      "get": {
        "tags": ["Podziały administracyjne"],
        "summary": "Szczegóły gminy",
        "description": "`municipality` (z `kind`: `miejska`/`wiejska`/`miejsko-wiejska`), `totals`, `localities[]` (miejscowości gminy z placówkami, z `isSeat` — heurystyka imiennika, brak trafienia nie oznacza braku siedziby), `seat`, `nearbyMunicipalities[]`, `educationStats[]`, `educationBenchmarks` oraz `indicators[]` (wskaźniki GUS BDL: żłobki na 1000 dzieci do lat 3, wychowanie przedszkolne na 1000 dzieci 3–6 lat, współczynniki skolaryzacji; roczniki wskaźników się różnią).\n\n**Gmina miejska zwraca `{ \"redirect\": { \"slug\": … } }`** — jej strona to strona miasta.",
        "operationId": "getMunicipality",
        "parameters": [
          { "$ref": "#/components/parameters/VoivodeshipPath" },
          { "$ref": "#/components/parameters/CountyPath" },
          {
            "name": "gmina",
            "in": "path",
            "required": true,
            "description": "Slug gminy — unikalny tylko w obrębie powiatu.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "Szczegóły gminy albo obiekt `redirect` dla gminy miejskiej.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/voivodeships/{slug}": {
      "get": {
        "tags": ["Podziały administracyjne"],
        "summary": "Statystyki województwa",
        "description": "Rozbicie na typy placówek, 10 największych miast oraz `education[]` — oficjalne szeregi GUS BDL per rok (2008–2024): `preschools`, `preschoolChildren`, `preschoolSections`, `primarySchools`, `primaryPupils`, `primarySections`. `null` to „brak informacji”, nigdy zero ani brak szkoły.\n\n`localitiesWithPreschoolChildren` i `localitiesWithPrimaryPupils` to zliczenia po pełnym zbiorze miejscowościowym BDL, nigdy sumy wartości. Licznik przedszkolny za 2016 niesie ogólnokrajową zapaść sprawozdawczą — to nie jest fala zamknięć.",
        "operationId": "getVoivodeship",
        "parameters": [{ "$ref": "#/components/parameters/VoivodeshipPathSlug" }],
        "responses": {
          "200": {
            "description": "Statystyki województwa.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/statistics": {
      "get": {
        "tags": ["Statystyki"],
        "summary": "Agregaty ogólnokrajowe",
        "description": "Liczby placówek wg typu, subtypu i województwa, top 10 miast, podział publiczne/prywatne oraz skalary `sio*` — krajowe sumy SIO za najnowszy rok szkolny (`sioSchoolYear`, `sioPupils`, `sioClasses`, `sioTeacherFte`, `sioPreschoolCapacity`, `sioPreschoolEnrolled` i pozostałe).\n\nSIO obejmuje wyłącznie placówki MEN — żłobki i uczelnie nie raportują — więc tych sum nie należy dzielić przez liczby rejestrowe ani zestawiać z nimi jako sprzeczności.",
        "operationId": "getStatistics",
        "responses": {
          "200": {
            "description": "Agregaty krajowe.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/statistics/fees": {
      "get": {
        "tags": ["Statystyki"],
        "summary": "Statystyki opłat w żłobkach i przedszkolach",
        "description": "Gdy w mieście jest za mało danych, zakres rozszerza się automatycznie do województwa, a potem do kraju — pole `scope` mówi, jakiego obszaru faktycznie dotyczy wynik.\n\nDla `type=PRESCHOOL` odpowiedź niesie też `occupancy` (miejsca vs przyjęci wg SIO) z **własnym** rozszerzaniem zakresu, więc `occupancy.scope` bywa węższy niż `scope` opłat. Dla `type=NURSERY` zawsze `occupancy: null` — żłobki nie raportują do SIO.",
        "operationId": "getFeeStatistics",
        "parameters": [
          {
            "name": "type",
            "in": "query",
            "required": true,
            "description": "Typ placówki, którego dotyczą opłaty.",
            "schema": { "type": "string", "enum": ["NURSERY", "PRESCHOOL"] }
          },
          {
            "name": "cityId",
            "in": "query",
            "description": "Identyfikator miejscowości z `/api/locations/search`.",
            "schema": { "type": "integer" }
          },
          { "$ref": "#/components/parameters/VoivodeshipQuery" }
        ],
        "responses": {
          "200": {
            "description": "Statystyki opłat wraz z faktycznym zakresem (`scope`).",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "400": { "$ref": "#/components/responses/BadQuery" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/statistics/vocational-trends": {
      "get": {
        "tags": ["Statystyki"],
        "summary": "Krajowe trendy zawodów szkolnictwa branżowego",
        "description": "Dane SIO (dane.gov.pl, zbiór 1617, CC0): `years[]`, `latestYear`, `previousYear`, `professions[]` (nazwa, branża z klasyfikacji 2025/26, seria per rok szkolny: `pupils`, `pupilsFemale`, `firstYearPupils`, `youthWorkers`, `graduates`, `schools`) i `schoolTypes[]`.\n\nReguły interpretacji: `null` znaczy „nie opublikowano dla tego rocznika”, nigdy zero; absolwenci roku N pochodzą z pliku rocznika N+1, więc najnowszy rok ma `null`; `pupilsFemale` to dolne oszacowanie; reforma klasyfikacji z 2019 r. zrywa porównania przez granicę 2018/19→2019/20. To inne uniwersum pomiaru niż liczby rejestrowe placówek.",
        "operationId": "getVocationalTrends",
        "responses": {
          "200": {
            "description": "Trendy zawodów.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/statistics/demographic-forecast": {
      "get": {
        "tags": ["Statystyki"],
        "summary": "Prognoza demograficzna GUS w grupach wieku edukacyjnego",
        "description": "Prognoza ludności GUS na lata 2023–2060 (scenariusz główny, dane z 2024-07-12, CC BY 4.0): `poland[]` i `voivodeships[]` z seriami `age0_2`, `age3_6`, `age7_14`, `age15_19`, `total`.\n\nRok 2022 (`empiricalYear`) to dane rzeczywiste. Każda wartość to ludność mieszkająca na danym terenie (stan na 31 XII), **nie uczniowie zapisani do szkół** — nie dzielić przez liczby uczniów z SIO ani GUS BDL. `history[]` to historyczne szeregi krajowe GUS BDL 2008–2024.",
        "operationId": "getDemographicForecast",
        "responses": {
          "200": {
            "description": "Prognoza dla kraju i 16 województw.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/statistics/specialists": {
      "get": {
        "tags": ["Statystyki"],
        "summary": "Zatrudnienie specjalistów i nauczane języki",
        "description": "Dane z otwartego eksportu CSV RSPO: `registryAsOf` (stan rejestru), `national`, `byVoivodeship[]`, `byType[]`, `byUrbanRural[]` i `languages[]`.\n\nOdsetki licz **zawsze od `reporting`**, nigdy od pełnej liczby wpisów — to odsetek placówek, nie uczniów; rejestr nie niesie wymiaru etatu, więc nie odpowiada na pytanie o liczbę uczniów na psychologa.",
        "operationId": "getSpecialistStatistics",
        "responses": {
          "200": {
            "description": "Statystyki specjalistów.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/patrons/search": {
      "get": {
        "tags": ["Patroni"],
        "summary": "Wyszukiwarka patronów",
        "operationId": "searchPatrons",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "description": "Fraza, minimum 1 znak.",
            "schema": { "type": "string", "minLength": 1 },
            "example": "Jan"
          },
          {
            "name": "limit",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 30, "default": 10 }
          }
        ],
        "responses": {
          "200": {
            "description": "Patroni z liczbą placówek.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "400": { "$ref": "#/components/responses/BadQuery" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/patrons/top": {
      "get": {
        "tags": ["Patroni"],
        "summary": "Najpopularniejsi patroni",
        "description": "Patroni z liczbą placówek i `slug` strony patrona.",
        "operationId": "listTopPatrons",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 100 }
          }
        ],
        "responses": {
          "200": {
            "description": "Ranking patronów.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "400": { "$ref": "#/components/responses/BadQuery" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/patrons/stats": {
      "get": {
        "tags": ["Patroni"],
        "summary": "Statystyki patronów",
        "description": "Liczba placówek z patronem, top 10, najczęstszy patron wg typu placówki, podział na kategorie i płeć. Klasyfikacja jest automatyczna — `classifiedAt` mówi, kiedy powstała.",
        "operationId": "getPatronStatistics",
        "responses": {
          "200": {
            "description": "Statystyki patronów.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/patrons/{slug}": {
      "get": {
        "tags": ["Patroni"],
        "summary": "Patron po slugu",
        "description": "Zwraca `{ patron, count, slug }`. Patron z mniej niż 10 placówkami nie ma własnej strony i odpowiada 404.",
        "operationId": "getPatron",
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "example": "jan-pawel-ii"
          }
        ],
        "responses": {
          "200": {
            "description": "Patron z liczbą placówek.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/vocational/professions": {
      "get": {
        "tags": ["Kształcenie zawodowe"],
        "summary": "Zawody nauczane w technikach i szkołach branżowych",
        "description": "Zawody przypisane placówkom w RSPO — słownik do podpowiedzi i filtrowania. To nie to samo co `/api/statistics/vocational-trends`, które niesie krajowe szeregi SIO.",
        "operationId": "listVocationalProfessions",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "description": "Fraza w nazwie zawodu.",
            "schema": { "type": "string" },
            "example": "technik"
          },
          {
            "name": "limit",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 50, "default": 20 }
          }
        ],
        "responses": {
          "200": {
            "description": "Lista zawodów.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "400": { "$ref": "#/components/responses/BadQuery" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    }
  },
  "components": {
    "parameters": {
      "Page": {
        "name": "page",
        "in": "query",
        "description": "Numer strony, od 1.",
        "schema": { "type": "integer", "minimum": 1, "default": 1 }
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "description": "Liczba wyników na stronie.",
        "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 }
      },
      "Lat": {
        "name": "lat",
        "in": "query",
        "required": true,
        "description": "Szerokość geograficzna.",
        "schema": { "type": "number", "minimum": -90, "maximum": 90 },
        "example": 49.9828
      },
      "Lng": {
        "name": "lng",
        "in": "query",
        "required": true,
        "description": "Długość geograficzna.",
        "schema": { "type": "number", "minimum": -180, "maximum": 180 },
        "example": 18.4088
      },
      "VoivodeshipQuery": {
        "name": "voivodeship",
        "in": "query",
        "description": "Slug województwa.",
        "schema": { "$ref": "#/components/schemas/VoivodeshipSlug" }
      },
      "VoivodeshipPath": {
        "name": "woj",
        "in": "path",
        "required": true,
        "description": "Slug województwa.",
        "schema": { "$ref": "#/components/schemas/VoivodeshipSlug" }
      },
      "VoivodeshipPathSlug": {
        "name": "slug",
        "in": "path",
        "required": true,
        "description": "Slug województwa.",
        "schema": { "$ref": "#/components/schemas/VoivodeshipSlug" }
      },
      "CountyPath": {
        "name": "powiat",
        "in": "path",
        "required": true,
        "description": "Slug powiatu — unikalny tylko w obrębie województwa.",
        "schema": { "type": "string" }
      }
    },
    "responses": {
      "BadQuery": {
        "description": "Nieprawidłowe parametry zapytania.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      },
      "NotFound": {
        "description": "Zasób nie istnieje.",
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      },
      "RateLimited": {
        "description": "Przekroczony limit 120 zapytań na minutę z jednego adresu IP.",
        "headers": {
          "Retry-After": {
            "description": "Liczba sekund do zwolnienia limitu.",
            "schema": { "type": "integer" }
          }
        },
        "content": {
          "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
        }
      }
    },
    "schemas": {
      "InstitutionType": {
        "type": "string",
        "description": "Typ placówki. Przedszkole to `PRESCHOOL`, nie `KINDERGARTEN` — ta wartość należy do `subtype`.",
        "enum": [
          "NURSERY",
          "PRESCHOOL",
          "PRIMARY_SCHOOL",
          "SECONDARY_SCHOOL",
          "POST_SECONDARY",
          "HIGHER_EDUCATION",
          "CONTINUING_EDUCATION",
          "SCHOOL_COMPLEX"
        ]
      },
      "InstitutionSubtype": {
        "type": "string",
        "description": "Doprecyzowanie typu placówki.",
        "enum": [
          "CHILDRENS_CLUB",
          "KINDERGARTEN",
          "PRESCHOOL_POINT",
          "PRESCHOOL_TEAM",
          "ARTISTIC",
          "SPECIAL_NEEDS",
          "GENERAL_SECONDARY",
          "TECHNICAL_SECONDARY",
          "VOCATIONAL_1",
          "VOCATIONAL_2",
          "UNIVERSITY",
          "POLYTECHNIC",
          "ACADEMY",
          "VOCATIONAL_CENTER",
          "SKILLS_CENTER"
        ]
      },
      "OperatorType": {
        "type": "string",
        "description": "Organ prowadzący — kto prowadzi placówkę. Pierwsze sześć wartości pochodzi ze słownika `typPodmiotu` rejestru RSPO, pozostałe to formy prawne podmiotów niepublicznych. To nie jest podział na placówki publiczne i niepubliczne — do tego służy `isPublic`.",
        "enum": [
          "gmina",
          "miasto",
          "powiat",
          "samorzad_woj",
          "minister",
          "cuw",
          "fundacja",
          "stowarzyszenie",
          "koscielna",
          "spolka",
          "osoba_fizyczna"
        ]
      },
      "Religion": {
        "type": "string",
        "description": "Wyznanie placówki wyznaniowej. Brak wartości znaczy „placówka nie jest wyznaniowa”; nie ma osobnej wartości „świecka”.",
        "enum": [
          "katolicka",
          "chrzescijanska",
          "ewangelicka",
          "luteranska",
          "prawoslawna",
          "zydowska",
          "muzulmanska",
          "inne_wyznanie"
        ]
      },
      "Pedagogy": {
        "type": "string",
        "description": "Pedagogika alternatywna. Brak wartości to szkoła bez pedagogiki alternatywnej.",
        "enum": [
          "montessori",
          "waldorf",
          "reggio_emilia",
          "freinet",
          "democratic",
          "ib",
          "dalton",
          "korczak"
        ]
      },
      "HigherEducationProfile": {
        "type": "string",
        "description": "Profil uczelni wyższej.",
        "enum": ["ACADEMIC", "VOCATIONAL"]
      },
      "VoivodeshipSlug": {
        "type": "string",
        "description": "Slug województwa — nazwa bez polskich znaków.",
        "enum": [
          "dolnoslaskie",
          "kujawsko-pomorskie",
          "lubelskie",
          "lubuskie",
          "lodzkie",
          "malopolskie",
          "mazowieckie",
          "opolskie",
          "podkarpackie",
          "podlaskie",
          "pomorskie",
          "slaskie",
          "swietokrzyskie",
          "warminsko-mazurskie",
          "wielkopolskie",
          "zachodniopomorskie"
        ]
      },
      "Pagination": {
        "type": "object",
        "description": "Stronicowanie listy.",
        "properties": {
          "page": { "type": "integer" },
          "limit": { "type": "integer" },
          "total": { "type": "integer" },
          "pages": { "type": "integer" }
        }
      },
      "Error": {
        "type": "object",
        "description": "Błąd. `details` niesie rozbicie błędów walidacji, `requestId` — identyfikator żądania do zgłoszenia problemu.",
        "properties": {
          "error": { "type": "string" },
          "details": { "type": "object" },
          "requestId": { "type": "string" }
        },
        "required": ["error"]
      }
    }
  }
}
