Skip to content

Commit 482c7c1

Browse files
committed
Add API endpoint using netlify edge function 🪄
1 parent 99848b8 commit 482c7c1

File tree

6 files changed

+16632
-204
lines changed

6 files changed

+16632
-204
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
app/node_modules
22
.vscode
3+
4+
# Local Netlify folder
5+
.netlify

README.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,98 @@ npm install
6868
npm run build
6969
```
7070

71+
## API 🚀
72+
73+
WhereRoulette provides a simple API using Netlify Edge Functions to retrieve POIs within a specified region. Mix some randomness into your apps or get details about specific locations! ✨
74+
75+
### POI Endpoint ⚡
76+
77+
```bash
78+
GET https://whereroulette.com/api
79+
```
80+
81+
#### Query Parameters 🔍
82+
83+
| Parameter | Description | Required | Default |
84+
|-----------|-------------|----------|---------|
85+
| region | OpenStreetMap region ID | Yes | - |
86+
| type | POI category (drinks, cafe, food, park, climb) | No | drinks |
87+
| id | Specific OSM node ID (e.g., "node/11967421222" or just "11967421222") | No | - |
88+
89+
#### Usage Examples 📝
90+
91+
**Random POI:**
92+
93+
```bash
94+
https://whereroulette.com/api?region=62422&type=climb
95+
```
96+
97+
**Specific POI by ID:**
98+
99+
```bash
100+
https://whereroulette.com/api?region=62422&type=climb&id=node%2F11967421222
101+
```
102+
103+
#### Response Example 💾
104+
105+
```json
106+
{
107+
"osm_node": "11967421222",
108+
"name": "Bouldergarten",
109+
"type": "climb",
110+
"emoji": "🧗",
111+
"opening_hours": "Mo-Fr 10:00-23:00, Sa,Su 10:00-22:00",
112+
"url": "https://whereroulette.com/?region=62422&type=climb&id=node%2F11967421222",
113+
"coordinates": [13.4567, 52.4890]
114+
}
115+
```
116+
117+
#### Error Responses ⚠️
118+
119+
**Missing Region:**
120+
121+
```json
122+
{
123+
"error": "Missing required parameter: region"
124+
}
125+
```
126+
127+
**Invalid Category:**
128+
129+
```json
130+
{
131+
"error": "Invalid type. Must be one of: drinks, cafe, food, park, climb"
132+
}
133+
```
134+
135+
**No POIs Found:**
136+
137+
```json
138+
{
139+
"error": "No climb found in region 62422"
140+
}
141+
```
142+
143+
**Node Not Found:**
144+
145+
```json
146+
{
147+
"error": "Node with ID node/12345678 not found"
148+
}
149+
```
150+
151+
### Technical Implementation 🛠️
152+
153+
The API is implemented as a Netlify Edge Function, which provides fast, globally distributed response times. It directly queries the Overpass API to fetch OpenStreetMap data and converts it to a simplified format optimized for POI information.
154+
155+
The configuration in `netlify.toml` maps the `/api` path to the Edge Function:
156+
157+
```toml
158+
[[edge_functions]]
159+
function = "json"
160+
path = "/api"
161+
```
162+
71163
## Deployment
72164

73165
### Frontend - GitHub Pages
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import type { Context } from 'https://edge.netlify.com'
2+
3+
// Define categories (copied from main.ts)
4+
enum Category {
5+
Drinks = "drinks",
6+
Cafe = "cafe",
7+
Food = "food",
8+
Park = "park",
9+
Climb = "climb",
10+
}
11+
12+
// Define category details (copied from main.ts)
13+
const categories = {
14+
[Category.Drinks]: { tag: 'amenity~"^(pub|bar|biergarten)$"', emoji: "🍺" },
15+
[Category.Cafe]: { tag: 'amenity~"^(cafe)$"', emoji: "☕" },
16+
[Category.Food]: { tag: 'amenity~"^(restaurant|fast_food|food_court|ice_cream)$"', emoji: "🍴" },
17+
[Category.Park]: { tag: 'leisure~"^(park|garden)$"', emoji: "🌳" },
18+
[Category.Climb]: { tag: 'sport~"^(climbing|bouldering)$"', emoji: "🧗" },
19+
};
20+
21+
// Define response type
22+
type ApiResponse = {
23+
osm_node: string;
24+
name: string;
25+
type: string;
26+
emoji: string;
27+
opening_hours?: string;
28+
url: string;
29+
coordinates?: [number, number];
30+
error?: string;
31+
};
32+
33+
// Helper function to fetch data from Overpass API
34+
async function fetchOverpassData(overpassQuery: string) {
35+
const response = await fetch("https://www.overpass-api.de/api/interpreter", {
36+
method: "POST",
37+
headers: {
38+
"Content-Type": "application/x-www-form-urlencoded",
39+
},
40+
body: overpassQuery,
41+
});
42+
43+
if (response.ok) {
44+
return await response.json();
45+
} else {
46+
throw new Error(`Overpass API error: ${response.statusText}`);
47+
}
48+
}
49+
50+
// Function to generate query for POIs in a relation
51+
function poisInRelationQuery(relationID: number, category: Category): string {
52+
return `[out:json];node(area:${relationID + 3600000000})[${categories[category].tag}];out geom;`;
53+
}
54+
55+
// Function to generate query for a specific node
56+
function specificNodeQuery(nodeId: string): string {
57+
return `[out:json];node(${nodeId});out geom;`;
58+
}
59+
60+
// Simplified implementation of osmtogeojson for node elements only
61+
function simplifiedOsmToGeoJson(osmData: any) {
62+
const features = [];
63+
64+
// Process nodes from OSM data
65+
if (osmData.elements) {
66+
for (const element of osmData.elements) {
67+
if (element.type === 'node') {
68+
const properties: any = {};
69+
70+
// Copy all tags to properties
71+
if (element.tags) {
72+
Object.assign(properties, element.tags);
73+
}
74+
75+
// Add ID to properties
76+
properties.id = `node/${element.id}`;
77+
78+
// Create feature
79+
features.push({
80+
type: 'Feature',
81+
id: `node/${element.id}`,
82+
properties: properties,
83+
geometry: {
84+
type: 'Point',
85+
coordinates: [element.lon, element.lat]
86+
}
87+
});
88+
}
89+
}
90+
}
91+
92+
return {
93+
type: 'FeatureCollection',
94+
features: features
95+
};
96+
}
97+
98+
// Function to fetch POIs in a relation
99+
async function fetchPoisInRelation(relationID: string, category: Category) {
100+
const query = poisInRelationQuery(parseInt(relationID), category);
101+
const osmData = await fetchOverpassData(query);
102+
return simplifiedOsmToGeoJson(osmData);
103+
}
104+
105+
// Function to fetch a specific node by ID
106+
async function fetchSpecificNode(nodeId: string) {
107+
// Extract the numeric ID if it's in the format "node/123456"
108+
const numericId = nodeId.includes('/') ? nodeId.split('/')[1] : nodeId;
109+
const query = specificNodeQuery(numericId);
110+
const osmData = await fetchOverpassData(query);
111+
return simplifiedOsmToGeoJson(osmData);
112+
}
113+
114+
export default async (request: Request, context: Context) => {
115+
// Set CORS headers
116+
const headers = {
117+
"Access-Control-Allow-Origin": "*",
118+
"Access-Control-Allow-Headers": "Content-Type",
119+
"Content-Type": "application/json",
120+
};
121+
122+
// Handle preflight requests
123+
if (request.method === "OPTIONS") {
124+
return new Response("", {
125+
status: 204,
126+
headers,
127+
});
128+
}
129+
130+
try {
131+
// Parse query parameters from URL
132+
const url = new URL(request.url);
133+
const regionId = url.searchParams.get('region');
134+
const categoryName = url.searchParams.get('type') || 'drinks'; // Default to drinks
135+
const specificId = url.searchParams.get('id');
136+
137+
// Validate parameters
138+
if (!regionId) {
139+
return Response.json({
140+
error: "Missing required parameter: region"
141+
}, {
142+
status: 400,
143+
headers
144+
});
145+
}
146+
147+
// Validate category
148+
if (!Object.values(Category).includes(categoryName as Category)) {
149+
return Response.json({
150+
error: `Invalid type. Must be one of: ${Object.values(Category).join(", ")}`
151+
}, {
152+
status: 400,
153+
headers
154+
});
155+
}
156+
157+
const category = categoryName as Category;
158+
159+
let selectedFeature;
160+
let properties;
161+
162+
// If specific ID is provided, fetch that specific node
163+
if (specificId) {
164+
// Check if it's a full OSM ID (e.g., "node/123456") or just a numeric ID
165+
const nodeData = await fetchSpecificNode(specificId);
166+
167+
if (nodeData.features && nodeData.features.length > 0) {
168+
selectedFeature = nodeData.features[0];
169+
properties = selectedFeature.properties || {};
170+
} else {
171+
return Response.json({
172+
error: `Node with ID ${specificId} not found`
173+
}, {
174+
status: 404,
175+
headers
176+
});
177+
}
178+
} else {
179+
// Otherwise, fetch POIs for the region and category and select randomly
180+
const pois = await fetchPoisInRelation(regionId, category);
181+
182+
// Check if any POIs were found
183+
if (!pois.features || pois.features.length === 0) {
184+
return Response.json({
185+
error: `No ${category} found in region ${regionId}`
186+
}, {
187+
status: 404,
188+
headers
189+
});
190+
}
191+
192+
// Randomly select a POI
193+
const randomIndex = Math.floor(Math.random() * pois.features.length);
194+
selectedFeature = pois.features[randomIndex];
195+
properties = selectedFeature.properties || {};
196+
}
197+
198+
// Get the OSM ID
199+
const osmId = properties.id || "";
200+
const idParts = osmId.split("/");
201+
const osmNodeType = idParts[0] || "node";
202+
const osmNodeId = idParts[1] || "";
203+
204+
// Create response
205+
const response: ApiResponse = {
206+
osm_node: osmNodeId,
207+
name: properties.name || "Unnamed location",
208+
type: category,
209+
emoji: categories[category].emoji,
210+
opening_hours: properties.opening_hours,
211+
url: `https://whereroulette.com/?region=${regionId}&type=${category}&id=${encodeURIComponent(osmId)}`,
212+
};
213+
214+
// Add coordinates if it's a point
215+
if (selectedFeature.geometry.type === "Point") {
216+
response.coordinates = selectedFeature.geometry.coordinates as [number, number];
217+
}
218+
219+
return Response.json(response, { headers });
220+
} catch (error) {
221+
console.error("Error processing request:", error);
222+
return Response.json({
223+
error: "Internal server error"
224+
}, {
225+
status: 500,
226+
headers
227+
});
228+
}
229+
}

0 commit comments

Comments
 (0)