{"openapi":"3.1.0","info":{"title":"Villa Alosa availability API","version":"1.0.0","summary":"Live availability of the three units of Villa Alosa in Céret.","description":"Public, read-only, unauthenticated availability API for Villa Alosa in Céret, let three ways: `upstairs` (Alosa Balcon), `downstairs` (Alosa Terrace) and `whole` (Villa Alosa XL, both apartments). Availability is *effective*: a whole-house stay blocks both apartments, and a stay in either apartment blocks the whole house.\n\nDates are calendar dates, `YYYY-MM-DD`, no times or zones. Ranges are half-open `[start, end)`: `start` is the first occupied night (check-in day), `end` is the departure day and is NOT occupied — a new guest can check in on `end`.\n\nEvery response carries `Access-Control-Allow-Origin: *`. Successful responses are cached for 300 s (`Cache-Control: public, max-age=300, stale-while-revalidate=600`); errors are `no-store`. Only GET, HEAD and OPTIONS are accepted; anything else is 405 `method_not_allowed`. Nothing here exposes guests, platforms, notes or feed URLs — only units and blocked date ranges.","contact":{"name":"Villa Alosa","url":"https://alosa.fr"}},"servers":[{"url":"https://api.alosa.fr"}],"security":[],"tags":[{"name":"availability","description":"Units and their effective blocked dates."},{"name":"meta","description":"Health and this description."}],"paths":{"/v1/units":{"get":{"operationId":"listUnits","tags":["availability"],"summary":"The three units and how they block each other","responses":{"200":{"description":"The units, top to bottom like the house: upstairs, downstairs, whole.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"type":"object","required":["units"],"properties":{"units":{"type":"array","items":{"$ref":"#/components/schemas/Unit"}}}}}}}}}},"/v1/availability":{"get":{"operationId":"getAvailability","tags":["availability"],"summary":"Effective blocked ranges per unit in a window","description":"Merged effective blocked ranges per unit, clipped to `[from, to)`. Ranges are half-open `[start, end)`: `start` is the first occupied night (check-in day), `end` is the departure day and is NOT occupied — a new guest can check in on `end`. A date is a valid check-in if it is not a blocked night; a valid check-out if the previous night is not blocked.","parameters":[{"name":"from","in":"query","required":false,"description":"Window start (inclusive). Default: today in Europe/Paris.","schema":{"$ref":"#/components/schemas/ISODate"}},{"name":"to","in":"query","required":false,"description":"Window end (exclusive). Default: `from` + 365 days. Must be after `from` (else `bad_range`) and at most 731 days after it (else `range_too_long`).","schema":{"$ref":"#/components/schemas/ISODate"}},{"name":"units","in":"query","required":false,"description":"Comma-separated unit ids to include (default: all). Unknown ids → 400 `bad_unit`.","style":"form","explode":false,"schema":{"type":"array","items":{"$ref":"#/components/schemas/UnitId"}},"example":["downstairs","whole"]}],"responses":{"200":{"description":"Blocked ranges per requested unit.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AvailabilityResponse"}}}},"400":{"description":"`bad_date`, `bad_range`, `range_too_long` or `bad_unit`.","headers":{"Cache-Control":{"$ref":"#/components/headers/NoStore"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/availability/check":{"get":{"operationId":"checkStay","tags":["availability"],"summary":"Is this stay free?","description":"Whether `unit` is free for the stay `[checkin, checkout)`, plus the conflicting effective blocked ranges (whole, not clipped to the stay). Ranges are half-open `[start, end)`: `start` is the first occupied night (check-in day), `end` is the departure day and is NOT occupied — a new guest can check in on `end`.","parameters":[{"name":"unit","in":"query","required":true,"description":"Unit id; anything else → 400 `bad_unit`.","schema":{"$ref":"#/components/schemas/UnitId"}},{"name":"checkin","in":"query","required":true,"description":"Arrival day (first night).","schema":{"$ref":"#/components/schemas/ISODate"}},{"name":"checkout","in":"query","required":true,"description":"Departure day (not a night). Must be after `checkin` (else `bad_range`); at most 731 nights (else `range_too_long`).","schema":{"$ref":"#/components/schemas/ISODate"}}],"responses":{"200":{"description":"Availability of the stay.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckResponse"}}}},"400":{"description":"`bad_unit`, `bad_date` (missing or invalid date), `bad_range` or `range_too_long`.","headers":{"Cache-Control":{"$ref":"#/components/headers/NoStore"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/calendar/{unit}.ics":{"get":{"operationId":"getCalendar","tags":["availability"],"summary":"Merged \"Not available\" iCal feed for one unit","description":"The effective blocked ranges from today − 30 days to today + 730 days as all-day VEVENTs (DTEND = departure day). The URL to paste into each platform's \"import calendar\".","parameters":[{"name":"unit","in":"path","required":true,"schema":{"$ref":"#/components/schemas/UnitId"}}],"responses":{"200":{"description":"iCalendar feed.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"text/calendar":{"schema":{"type":"string"}}}},"404":{"description":"`not_found` — unknown unit.","headers":{"Cache-Control":{"$ref":"#/components/headers/NoStore"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/v1/health":{"get":{"operationId":"getHealth","tags":["meta"],"summary":"Feed freshness summary","description":"How fresh the underlying platform feeds are. No URLs, no error text.","responses":{"200":{"description":"Health summary.","headers":{"Cache-Control":{"$ref":"#/components/headers/CacheControl"}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Health"}}}}}}},"/openapi.json":{"get":{"operationId":"getOpenApi","tags":["meta"],"summary":"This document (also at /v1/openapi.json)","responses":{"200":{"description":"OpenAPI 3.1 description.","content":{"application/json":{"schema":{"type":"object"}}}}}}}},"components":{"schemas":{"ISODate":{"type":"string","format":"date","pattern":"^\\d{4}-\\d{2}-\\d{2}$","description":"Calendar date, YYYY-MM-DD.","example":"2026-12-20"},"UnitId":{"type":"string","enum":["upstairs","downstairs","whole"],"description":"upstairs = Alosa Balcon, downstairs = Alosa Terrace, whole = Villa Alosa XL."},"DateRange":{"type":"object","description":"A blocked range. Ranges are half-open `[start, end)`: `start` is the first occupied night (check-in day), `end` is the departure day and is NOT occupied — a new guest can check in on `end`.","required":["start","end"],"properties":{"start":{"$ref":"#/components/schemas/ISODate"},"end":{"$ref":"#/components/schemas/ISODate"}},"example":{"start":"2026-12-20","end":"2026-12-27"}},"Unit":{"type":"object","required":["id","name","description","sleeps","bedrooms","blockedBy"],"properties":{"id":{"$ref":"#/components/schemas/UnitId"},"name":{"type":"string","example":"Alosa Balcon"},"description":{"type":"string"},"sleeps":{"type":"integer"},"bedrooms":{"type":"integer"},"blockedBy":{"type":"array","items":{"$ref":"#/components/schemas/UnitId"},"description":"Units whose bookings make this unit unavailable (always includes itself)."}}},"AvailabilityResponse":{"type":"object","required":["from","to","generatedAt","units"],"properties":{"from":{"$ref":"#/components/schemas/ISODate"},"to":{"$ref":"#/components/schemas/ISODate"},"generatedAt":{"type":"string","format":"date-time"},"units":{"type":"object","description":"One key per requested unit id.","propertyNames":{"$ref":"#/components/schemas/UnitId"},"additionalProperties":{"type":"object","required":["blocked"],"properties":{"blocked":{"type":"array","items":{"$ref":"#/components/schemas/DateRange"},"description":"Merged, sorted, clipped to [from, to)."}}}}}},"CheckResponse":{"type":"object","required":["unit","checkin","checkout","nights","available","conflicts"],"properties":{"unit":{"$ref":"#/components/schemas/UnitId"},"checkin":{"$ref":"#/components/schemas/ISODate"},"checkout":{"$ref":"#/components/schemas/ISODate"},"nights":{"type":"integer","minimum":1,"maximum":731},"available":{"type":"boolean"},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/DateRange"},"description":"Effective blocked ranges overlapping the stay (empty when available)."}}},"Health":{"type":"object","required":["ok","generatedAt","feeds","lastSuccess","stale"],"properties":{"ok":{"type":"boolean","description":"At least one enabled feed and not stale."},"generatedAt":{"type":"string","format":"date-time"},"feeds":{"type":"object","required":["total","enabled","failing"],"properties":{"total":{"type":"integer"},"enabled":{"type":"integer"},"failing":{"type":"integer"}}},"lastSuccess":{"type":["string","null"],"format":"date-time"},"stale":{"type":"boolean","description":"No feed succeeded in the last 2 hours."}}},"Error":{"type":"object","required":["error","message"],"properties":{"error":{"type":"string","description":"snake_case code.","examples":["bad_date","bad_range","range_too_long","bad_unit","not_found","method_not_allowed","internal_error"]},"message":{"type":"string","description":"Human-readable detail."}}}},"headers":{"CacheControl":{"description":"public, max-age=300, stale-while-revalidate=600","schema":{"type":"string"}},"NoStore":{"description":"no-store","schema":{"type":"string"}}}}}