openapi: 3.0.3 info: title: Kite Map API description: > Open-source API for the FablePool Kite Flying Map. Public read access to spots, weather, and kite-flyability scores; authenticated writes for community contributions. Units are metric: wind in m/s, temperature in °C, precipitation in mm/h, direction in degrees true (FROM which the wind blows). version: 1.0.0 license: name: MIT url: https://opensource.org/licenses/MIT servers: - url: https://api.kitemap.example/v1 description: Reference deployment (self-hosters substitute their own host) tags: - name: system - name: auth - name: users - name: spots - name: weather - name: flyability - name: reports - name: hazards paths: /health: get: tags: [system] operationId: getHealth summary: Liveness and readiness probe responses: "200": description: Service healthy content: application/json: schema: type: object required: [status, version] properties: status: { type: string, enum: [ok, degraded] } version: { type: string, example: "1.0.0" } weather_upstream: type: string enum: [ok, stale, unavailable] /auth/register: post: tags: [auth] operationId: register summary: Create an account requestBody: required: true content: application/json: schema: type: object required: [email, password, display_name] properties: email: { type: string, format: email } password: { type: string, minLength: 10, maxLength: 128 } display_name: { type: string, minLength: 2, maxLength: 50 } responses: "201": description: Account created content: application/json: schema: { $ref: "#/components/schemas/TokenEnvelope" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Validation" } /auth/login: post: tags: [auth] operationId: login summary: Exchange credentials for tokens requestBody: required: true content: application/json: schema: type: object required: [email, password] properties: email: { type: string, format: email } password: { type: string } responses: "200": description: Authenticated content: application/json: schema: { $ref: "#/components/schemas/TokenEnvelope" } "401": { $ref: "#/components/responses/Unauthorized" } /auth/refresh: post: tags: [auth] operationId: refreshToken summary: Rotate refresh token requestBody: required: true content: application/json: schema: type: object required: [refresh_token] properties: refresh_token: { type: string } responses: "200": description: New token pair (old refresh token revoked) content: application/json: schema: { $ref: "#/components/schemas/TokenEnvelope" } "401": { $ref: "#/components/responses/Unauthorized" } /auth/logout: post: tags: [auth] operationId: logout summary: Revoke a refresh token security: [{ bearerAuth: [] }] requestBody: required: true content: application/json: schema: type: object required: [refresh_token] properties: refresh_token: { type: string } responses: "204": { description: Token revoked (or already invalid) } /users/me: get: tags: [users] operationId: getCurrentUser summary: Current user profile security: [{ bearerAuth: [] }] responses: "200": description: Profile content: application/json: schema: { $ref: "#/components/schemas/User" } "401": { $ref: "#/components/responses/Unauthorized" } patch: tags: [users] operationId: updateCurrentUser summary: Update profile and unit preferences security: [{ bearerAuth: [] }] requestBody: required: true content: application/json: schema: type: object properties: display_name: { type: string, minLength: 2, maxLength: 50 } preferred_speed_unit: type: string enum: [ms, kts, kmh, mph, beaufort] preferred_kite_type: $ref: "#/components/schemas/KiteType" responses: "200": description: Updated profile content: application/json: schema: { $ref: "#/components/schemas/User" } "401": { $ref: "#/components/responses/Unauthorized" } "422": { $ref: "#/components/responses/Validation" } /users/me/favorites: get: tags: [users] operationId: listFavorites summary: List favorite spots security: [{ bearerAuth: [] }] parameters: - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" responses: "200": description: Favorite spots content: application/json: schema: allOf: - $ref: "#/components/schemas/PageEnvelope" - type: object properties: data: type: array items: { $ref: "#/components/schemas/SpotSummary" } "401": { $ref: "#/components/responses/Unauthorized" } /users/me/favorites/{spotId}: put: tags: [users] operationId: addFavorite summary: Add a spot to favorites (idempotent) security: [{ bearerAuth: [] }] parameters: [{ $ref: "#/components/parameters/SpotId" }] responses: "204": { description: Favorited (or already favorited) } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" } delete: tags: [users] operationId: removeFavorite summary: Remove a spot from favorites security: [{ bearerAuth: [] }] parameters: [{ $ref: "#/components/parameters/SpotId" }] responses: "204": { description: Removed (or was not favorited) } "401": { $ref: "#/components/responses/Unauthorized" } /spots: get: tags: [spots] operationId: listSpots summary: Search spots by bounding box, proximity, or text parameters: - name: bbox in: query description: "minLon,minLat,maxLon,maxLat (max area 10°×10°)" schema: { type: string, example: "-122.6,37.6,-122.3,37.9" } - name: near in: query description: "lat,lon — sorts by distance within 100 km" schema: { type: string, example: "37.7694,-122.5107" } - name: q in: query schema: { type: string, maxLength: 100 } - name: spot_type in: query explode: true schema: type: array items: { $ref: "#/components/schemas/SpotType" } - name: kite_type in: query schema: { $ref: "#/components/schemas/KiteType" } - name: min_score in: query schema: { type: integer, minimum: 0, maximum: 100 } - name: verified in: query schema: { type: boolean } - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" responses: "200": description: Matching spots content: application/json: schema: allOf: - $ref: "#/components/schemas/PageEnvelope" - type: object properties: data: type: array items: { $ref: "#/components/schemas/SpotSummary" } "422": { $ref: "#/components/responses/Validation" } post: tags: [spots] operationId: createSpot summary: Propose a new spot security: [{ bearerAuth: [] }] parameters: [{ $ref: "#/components/parameters/IdempotencyKey" }] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/SpotCreate" } responses: "201": description: Spot created (unverified until community review) headers: Location: schema: { type: string, example: /v1/spots/spt_01HXY3 } content: application/json: schema: { $ref: "#/components/schemas/Spot" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Validation" } /spots.geojson: get: tags: [spots] operationId: listSpotsGeoJson summary: Search spots, returned as a GeoJSON FeatureCollection parameters: - name: bbox in: query required: true schema: { type: string, example: "-122.6,37.6,-122.3,37.9" } - name: kite_type in: query schema: { $ref: "#/components/schemas/KiteType" } responses: "200": description: FeatureCollection of Point features; properties mirror SpotSummary content: application/geo+json: schema: type: object required: [type, features] properties: type: { type: string, enum: [FeatureCollection] } features: type: array items: type: object required: [type, geometry, properties] properties: type: { type: string, enum: [Feature] } geometry: type: object required: [type, coordinates] properties: type: { type: string, enum: [Point] } coordinates: type: array minItems: 2 maxItems: 2 items: { type: number } properties: $ref: "#/components/schemas/SpotSummary" /spots/{spotId}: get: tags: [spots] operationId: getSpot summary: Spot detail parameters: [{ $ref: "#/components/parameters/SpotId" }] responses: "200": description: Spot detail content: application/json: schema: { $ref: "#/components/schemas/Spot" } "404": { $ref: "#/components/responses/NotFound" } patch: tags: [spots] operationId: updateSpot summary: Edit a spot (creator or moderator) security: [{ bearerAuth: [] }] parameters: [{ $ref: "#/components/parameters/SpotId" }] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/SpotUpdate" } responses: "200": description: Updated spot content: application/json: schema: { $ref: "#/components/schemas/Spot" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } "422": { $ref: "#/components/responses/Validation" } delete: tags: [spots] operationId: deleteSpot summary: Soft-delete a spot (moderator only) security: [{ bearerAuth: [] }] parameters: [{ $ref: "#/components/parameters/SpotId" }] responses: "204": { description: Soft-deleted } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /spots/{spotId}/forecast: get: tags: [weather] operationId: getSpotForecast summary: Hourly forecast for a spot parameters: - $ref: "#/components/parameters/SpotId" - $ref: "#/components/parameters/Hours" - $ref: "#/components/parameters/Start" responses: "200": description: Hourly forecast headers: ETag: { schema: { type: string } } Cache-Control: schema: type: string example: "public, max-age=600, stale-while-revalidate=3600" content: application/json: schema: { $ref: "#/components/schemas/Forecast" } "404": { $ref: "#/components/responses/NotFound" } "503": { $ref: "#/components/responses/UpstreamUnavailable" } /spots/{spotId}/flyability: get: tags: [flyability] operationId: getSpotFlyability summary: Flyability scores per kite type for a spot parameters: - $ref: "#/components/parameters/SpotId" - $ref: "#/components/parameters/Hours" - name: kite_type in: query schema: { $ref: "#/components/schemas/KiteType" } responses: "200": description: Hourly flyability headers: ETag: { schema: { type: string } } content: application/json: schema: { $ref: "#/components/schemas/FlyabilitySeries" } "404": { $ref: "#/components/responses/NotFound" } "503": { $ref: "#/components/responses/UpstreamUnavailable" } /spots/{spotId}/reports: get: tags: [reports] operationId: listSpotReports summary: Recent condition/session reports for a spot parameters: - $ref: "#/components/parameters/SpotId" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" responses: "200": description: Reports, newest first content: application/json: schema: allOf: - $ref: "#/components/schemas/PageEnvelope" - type: object properties: data: type: array items: { $ref: "#/components/schemas/Report" } "404": { $ref: "#/components/responses/NotFound" } post: tags: [reports] operationId: createReport summary: Submit a condition report security: [{ bearerAuth: [] }] parameters: - $ref: "#/components/parameters/SpotId" - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ReportCreate" } responses: "201": description: Report created headers: Location: { schema: { type: string } } content: application/json: schema: { $ref: "#/components/schemas/Report" } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Validation" } /reports/{reportId}: delete: tags: [reports] operationId: deleteReport summary: Delete own report (or any, as moderator) security: [{ bearerAuth: [] }] parameters: - name: reportId in: path required: true schema: { type: string, example: rpt_01HXY3 } responses: "204": { description: Deleted } "401": { $ref: "#/components/responses/Unauthorized" } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } /spots/{spotId}/hazards: get: tags: [hazards] operationId: listHazards summary: List hazards/obstacles for a spot parameters: [{ $ref: "#/components/parameters/SpotId" }] responses: "200": description: Active hazards content: application/json: schema: type: object required: [data] properties: data: type: array items: { $ref: "#/components/schemas/Hazard" } "404": { $ref: "#/components/responses/NotFound" } post: tags: [hazards] operationId: createHazard summary: Report a hazard at a spot security: [{ bearerAuth: [] }] parameters: - $ref: "#/components/parameters/SpotId" - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/HazardCreate" } responses: "201": description: Hazard recorded content: application/json: schema: { $ref: "#/components/schemas/Hazard" } "401": { $ref: "#/components/responses/Unauthorized" } "422": { $ref: "#/components/responses/Validation" } /weather/point: get: tags: [weather] operationId: getPointForecast summary: Hourly forecast for an arbitrary coordinate (snapped to 0.05° grid) parameters: - $ref: "#/components/parameters/Lat" - $ref: "#/components/parameters/Lon" - $ref: "#/components/parameters/Hours" - $ref: "#/components/parameters/Start" responses: "200": description: Forecast; includes grid_lat/grid_lon used for caching content: application/json: schema: allOf: - $ref: "#/components/schemas/Forecast" - type: object properties: grid_lat: { type: number } grid_lon: { type: number } "422": { $ref: "#/components/responses/Validation" } "503": { $ref: "#/components/responses/UpstreamUnavailable" } /flyability/point: get: tags: [flyability] operationId: getPointFlyability summary: Weather-only flyability for an arbitrary coordinate description: > Direction and obstacle components are null (no spot metadata), and each hour carries partial=true. parameters: - $ref: "#/components/parameters/Lat" - $ref: "#/components/parameters/Lon" - $ref: "#/components/parameters/Hours" - name: kite_type in: query schema: { $ref: "#/components/schemas/KiteType" } responses: "200": description: Hourly weather-only flyability content: application/json: schema: { $ref: "#/components/schemas/FlyabilitySeries" } "422": { $ref: "#/components/responses/Validation" } "503": { $ref: "#/components/responses/UpstreamUnavailable" } components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT parameters: SpotId: name: spotId in: path required: true schema: { type: string, example: spt_01HXY3 } Limit: name: limit in: query schema: { type: integer, minimum: 1, maximum: 200, default: 50 } Cursor: name: cursor in: query schema: { type: string } Hours: name: hours in: query schema: { type: integer, minimum: 1, maximum: 168, default: 48 } Start: name: start in: query schema: { type: string, format: date-time } Lat: name: lat in: query required: true schema: { type: number, minimum: -90, maximum: 90 } Lon: name: lon in: query required: true schema: { type: number, minimum: -180, maximum: 180 } IdempotencyKey: name: Idempotency-Key in: header schema: { type: string, format: uuid } responses: Unauthorized: description: Missing or invalid credentials content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } Forbidden: description: Authenticated but not permitted content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } NotFound: description: Resource does not exist (or is soft-deleted) content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } Conflict: description: Duplicate or conflicting resource content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } Validation: description: Request failed validation content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } UpstreamUnavailable: description: Weather provider unavailable and cache is empty/expired content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } schemas: Problem: type: object required: [type, title, status] properties: type: { type: string, example: "https://kitemap.example/errors/validation" } title: { type: string } status: { type: integer } detail: { type: string } instance: { type: string } errors: type: array items: type: object required: [field, message] properties: field: { type: string } message: { type: string } PageEnvelope: type: object required: [data, has_more] properties: data: type: array items: {} next_cursor: type: string nullable: true has_more: { type: boolean } KiteType: type: string enum: [single_line, sport_stunt, power_foil, kitesurf] SpotType: type: string enum: [beach, park, field, hill, water] WindObservation: type: object required: [speed_ms, direction_deg] properties: speed_ms: { type: number, minimum: 0 } gust_ms: { type: number, minimum: 0, nullable: true } direction_deg: { type: number, minimum: 0, maximum: 360 } SpotSummary: type: object required: [id, name, lat, lon, spot_type, verified] properties: id: { type: string } name: { type: string } lat: { type: number } lon: { type: number } spot_type: { $ref: "#/components/schemas/SpotType" } suitable_kite_types: type: array items: { $ref: "#/components/schemas/KiteType" } verified: { type: boolean } hazard_count: { type: integer, minimum: 0 } distance_m: type: number nullable: true description: Present only when the request used `near`. current_flyability: type: object nullable: true properties: as_of: { type: string, format: date-time } scores: type: object additionalProperties: { type: integer, minimum: 0, maximum: 100 } current_wind: allOf: [{ $ref: "#/components/schemas/WindObservation" }] nullable: true Spot: allOf: - $ref: "#/components/schemas/SpotSummary" - type: object required: [created_at, updated_at] properties: description: { type: string, nullable: true } preferred_wind_directions: type: array description: Degree ranges that work at this spot, e.g. [[240,330]] items: type: array minItems: 2 maxItems: 2 items: { type: number, minimum: 0, maximum: 360 } access_notes: { type: string, nullable: true } local_rules: { type: string, nullable: true } created_by: { type: string, nullable: true } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } SpotCreate: type: object required: [name, lat, lon, spot_type] properties: name: { type: string, minLength: 3, maxLength: 100 } lat: { type: number, minimum: -90, maximum: 90 } lon: { type: number, minimum: -180, maximum: 180 } spot_type: { $ref: "#/components/schemas/SpotType" } suitable_kite_types: type: array items: { $ref: "#/components/schemas/KiteType" } description: { type: string, maxLength: 2000 } preferred_wind_directions: type: array items: type: array minItems: 2 maxItems: 2 items: { type: number, minimum: 0, maximum: 360 } access_notes: { type: string, maxLength: 1000 } local_rules: { type: string, maxLength: 1000 } SpotUpdate: type: object description: All fields optional; only provided fields are changed. properties: name: { type: string, minLength: 3, maxLength: 100 } spot_type: { $ref: "#/components/schemas/SpotType" } suitable_kite_types: type: array items: { $ref: "#/components/schemas/KiteType" } description: { type: string, maxLength: 2000 } preferred_wind_directions: type: array items: type: array minItems: 2 maxItems: 2 items: { type: number, minimum: 0, maximum: 360 } access_notes: { type: string, maxLength: 1000 } local_rules: { type: string, maxLength: 1000 } verified: type: boolean description: Moderator only. ForecastHour: type: object required: [time, wind_speed_ms, wind_direction_deg] properties: time: { type: string, format: date-time } wind_speed_ms: { type: number, minimum: 0 } wind_gust_ms: { type: number, minimum: 0, nullable: true } wind_direction_deg: { type: number, minimum: 0, maximum: 360 } temperature_c: { type: number } precipitation_mmh: { type: number, minimum: 0 } precipitation_probability_pct: type: integer minimum: 0 maximum: 100 nullable: true cloud_cover_pct: { type: integer, minimum: 0, maximum: 100, nullable: true } is_daylight: { type: boolean } Forecast: type: object required: [source, model_run, hours] properties: spot_id: { type: string, nullable: true } source: { type: string, example: open-meteo } model: { type: string, example: best_match } model_run: { type: string, format: date-time } elevation_m: { type: number, nullable: true } hours: type: array items: { $ref: "#/components/schemas/ForecastHour" } FlyabilityComponent: type: object nullable: true properties: score: { type: integer, minimum: 0, maximum: 100 } penalty: { type: integer, maximum: 0 } value_ms: { type: number } value_mmh: { type: number } value: { type: number } note: { type: string } hazard_ids: type: array items: { type: string } FlyabilityHour: type: object required: [time, kite_type, score, band] properties: time: { type: string, format: date-time } kite_type: { $ref: "#/components/schemas/KiteType" } score: { type: integer, minimum: 0, maximum: 100 } band: type: string enum: [no_fly, marginal, fair, good, excellent] partial: type: boolean description: True when direction/obstacle components were unavailable. components: type: object properties: wind_speed: { $ref: "#/components/schemas/FlyabilityComponent" } gust_ratio: { $ref: "#/components/schemas/FlyabilityComponent" } precipitation: { $ref: "#/components/schemas/FlyabilityComponent" } direction: { $ref: "#/components/schemas/FlyabilityComponent" } obstacles: { $ref: "#/components/schemas/FlyabilityComponent" } advisories: type: array items: { type: string } FlyabilitySeries: type: object required: [rubric_version, hours] properties: spot_id: { type: string, nullable: true } grid_lat: { type: number, nullable: true } grid_lon: { type: number, nullable: true } rubric_version: { type: string, example: "1.0" } model_run: { type: string, format: date-time } hours: type: array items: { $ref: "#/components/schemas/FlyabilityHour" } Report: type: object required: [id, spot_id, user, observed_at, created_at] properties: id: { type: string, example: rpt_01HXY3 } spot_id: { type: string } user: type: object required: [id, display_name] properties: id: { type: string } display_name: { type: string } observed_at: { type: string, format: date-time } kite_type: allOf: [{ $ref: "#/components/schemas/KiteType" }] nullable: true observed_wind: allOf: [{ $ref: "#/components/schemas/WindObservation" }] nullable: true crowding: type: string enum: [empty, light, moderate, busy, packed] nullable: true conditions_rating: type: integer minimum: 1 maximum: 5 nullable: true comment: { type: string, nullable: true } created_at: { type: string, format: date-time } ReportCreate: type: object required: [observed_at] properties: observed_at: type: string format: date-time description: Not in the future; at most 48 h old. kite_type: { $ref: "#/components/schemas/KiteType" } observed_wind_speed_ms: { type: number, minimum: 0, maximum: 60 } observed_gust_ms: { type: number, minimum: 0, maximum: 80 } observed_direction_deg: { type: number, minimum: 0, maximum: 360 } crowding: type: string enum: [empty, light, moderate, busy, packed] conditions_rating: { type: integer, minimum: 1, maximum: 5 } comment: { type: string, maxLength: 1000 } Hazard: type: object required: [id, spot_id, hazard_type, severity, created_at, active] properties: id: { type: string, example: hzd_01HXY3 } spot_id: { type: string } hazard_type: type: string enum: - power_lines - trees - buildings - airport_proximity - road_traffic - water_current - rocks - crowds - wildlife_protection - other severity: type: string enum: [info, caution, danger] description: { type: string, nullable: true } bearing_deg: type: number minimum: 0 maximum: 360 nullable: true description: Direction from the spot toward the hazard, if localized. active: { type: boolean } reported_by: { type: string, nullable: true } created_at: { type: string, format: date-time } HazardCreate: type: object required: [hazard_type, severity] properties: hazard_type: type: string enum: - power_lines - trees - buildings - airport_proximity - road_traffic - water_current - rocks - crowds - wildlife_protection - other severity: type: string enum: [info, caution, danger] description: { type: string, maxLength: 500 } bearing_deg: { type: number, minimum: 0, maximum: 360 } User: type: object required: [id, email, display_name, role, created_at] properties: id: { type: string, example: usr_01HXY3 } email: { type: string, format: email } email_verified: { type: boolean } display_name: { type: string } role: type: string enum: [user, moderator, admin] preferred_speed_unit: type: string enum: [ms, kts, kmh, mph, beaufort] default: ms preferred_kite_type: allOf: [{ $ref: "#/components/schemas/KiteType" }] nullable: true created_at: { type: string, format: date-time } TokenEnvelope: type: object required: [user, access_token, refresh_token, expires_in] properties: user: { $ref: "#/components/schemas/User" } access_token: { type: string } refresh_token: { type: string } expires_in: type: integer description: Access-token lifetime in seconds. example: 900