Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app-modules/api/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Versioned public REST API exposing HackGreenville events and organizations as JS
## Routes
- `GET /api/v0/events`, `GET /api/v0/orgs` — legacy flat format (Drupal-era compatibility)
- `GET /api/v1/events`, `GET /api/v1/organizations` — modern paginated format
- `GET /api/v1/map-layers` — community-curated map layer index (paginated, filterable by title)
- `GET /api/v1/map-layers/{slug}/geojson` — raw GeoJSON FeatureCollection for a map layer
- Docs: `/docs/api` (auto-generated by Scribe)

## Testing
Expand Down
4 changes: 4 additions & 0 deletions app-modules/api/routes/api-routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

use HackGreenville\Api\Http\Controllers\EventApiV0Controller;
use HackGreenville\Api\Http\Controllers\EventApiV1Controller;
use HackGreenville\Api\Http\Controllers\MapLayerGeoJsonController;
use HackGreenville\Api\Http\Controllers\MapLayersApiV1Controller;
use HackGreenville\Api\Http\Controllers\OrgsApiV0Controller;
use HackGreenville\Api\Http\Controllers\OrgsApiV1Controller;

Expand All @@ -13,4 +15,6 @@

Route::get('v1/events', EventApiV1Controller::class)->name('api.v1.events.index');
Route::get('v1/organizations', OrgsApiV1Controller::class)->name('api.v1.organizations.index');
Route::get('v1/map-layers', MapLayersApiV1Controller::class)->name('api.v1.map-layers.index');
Route::get('v1/map-layers/{mapLayer:slug}/geojson', MapLayerGeoJsonController::class)->name('api.v1.map-layers.geojson');
});
9 changes: 6 additions & 3 deletions app-modules/api/src/Http/Controllers/EventApiV1Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ public function __invoke(EventApiV1Request $request)
});
})
->when($request->filled('name'), function (Builder $query) use ($request) {
$query->where('event_name', 'like', '%' . $request->input('name') . '%');
$name = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $request->input('name'));
$query->whereRaw("event_name LIKE ? ESCAPE '!'", ['%' . $name . '%']);
})
->when($request->filled('org_name'), function (Builder $query) use ($request) {
$query->where('group_name', 'like', '%' . $request->input('org_name') . '%');
$orgName = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $request->input('org_name'));
$query->whereRaw("group_name LIKE ? ESCAPE '!'", ['%' . $orgName . '%']);
})
->when($request->filled('service'), function (Builder $query) use ($request) {
$query->where('service', $request->input('service'));
Expand All @@ -49,7 +51,8 @@ public function __invoke(EventApiV1Request $request)
})
->when($request->filled('venue_city'), function (Builder $query) use ($request) {
$query->whereHas('venue', function (Builder $query) use ($request) {
$query->where('city', 'like', '%' . $request->input('venue_city') . '%');
$city = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $request->input('venue_city'));
$query->whereRaw("city LIKE ? ESCAPE '!'", ['%' . $city . '%']);
});
})
->when($request->filled('venue_state'), function (Builder $query) use ($request) {
Expand Down
35 changes: 35 additions & 0 deletions app-modules/api/src/Http/Controllers/MapLayerGeoJsonController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace HackGreenville\Api\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Models\MapLayer;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;

class MapLayerGeoJsonController extends Controller
{
/**
* Map Layer GeoJSON
*
* Returns the raw GeoJSON FeatureCollection for a specific map layer.
*
* The response is suitable for direct use with mapping libraries like Leaflet, Mapbox, or Google Maps.
*
* @urlParam mapLayer_slug string required The slug of the map layer. Example: breweries
*
* @response 200 scenario="Success" {"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[-82.398500,34.850700]},"properties":{"title":"Example Location"}}]}
* @response 404 scenario="Not found" {"message":"GeoJSON data not found for this map layer."}
*/
public function __invoke(MapLayer $mapLayer)
{
$path = "geojson/" . basename($mapLayer->slug) . ".geojson";

if ( ! Storage::disk('local')->exists($path)) {
abort(Response::HTTP_NOT_FOUND, 'GeoJSON data not found for this map layer.');
}

return response(Storage::disk('local')->get($path))
->header('Content-Type', 'application/geo+json');
}
}
43 changes: 43 additions & 0 deletions app-modules/api/src/Http/Controllers/MapLayersApiV1Controller.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace HackGreenville\Api\Http\Controllers;

use App\Http\Controllers\Controller;
use App\Models\MapLayer;
use HackGreenville\Api\Http\Requests\MapLayersApiV1Request;
use HackGreenville\Api\Resources\MapLayers\V1\MapLayerCollection;
use Illuminate\Database\Eloquent\Builder;

class MapLayersApiV1Controller extends Controller
{
/**
* Map Layers API v1
*
* This API provides access to community-curated map layer data for the Greenville, SC area.
*
* Each map layer represents a collection of geographic features (e.g. breweries, parks, trails)
* sourced from community-maintained Google Spreadsheets and served as GeoJSON.
*
* @apiResource HackGreenville\Api\Resources\MapLayers\V1\MapLayerCollection
* @apiResourceModel App\Models\MapLayer states=forDocumentation
*/
public function __invoke(MapLayersApiV1Request $request)
{
$query = MapLayer::query()
->when($request->filled('title'), function (Builder $query) use ($request) {
$title = str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $request->input('title'));
$query->whereRaw("title LIKE ? ESCAPE '!'", ['%' . $title . '%']);
})
->when($request->filled('sort_by'), function (Builder $query) use ($request) {
$sortDirection = $request->input('sort_direction') === 'desc' ? 'desc' : 'asc';
$query->orderBy($request->input('sort_by'), $sortDirection);
}, function (Builder $query) {
$query->orderBy('title', 'asc');
});

$perPage = $request->input('per_page', 15);
$mapLayers = $query->paginate($perPage);

return new MapLayerCollection($mapLayers);
}
}
57 changes: 57 additions & 0 deletions app-modules/api/src/Http/Requests/MapLayersApiV1Request.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace HackGreenville\Api\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class MapLayersApiV1Request extends FormRequest
{
public function authorize()
{
return true;
}

public function rules()
{
return [
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'page' => ['nullable', 'integer', 'min:1'],
'title' => ['nullable', 'string', 'max:255'],
'sort_by' => [
'nullable',
'string',
Rule::in(['title', 'updated_at', 'created_at']),
],
'sort_direction' => ['nullable', 'string', Rule::in(['asc', 'desc'])],
];
}

public function messages()
{
return [
'per_page.min' => 'The per page value must be at least 1.',
'per_page.max' => 'The per page value cannot exceed 100.',
'page.min' => 'The page value must be at least 1.',
'sort_by.in' => 'The sort by field must be one of: title, updated_at, created_at.',
'sort_direction.in' => 'The sort direction must be either asc or desc.',
];
}

public function queryParameters()
{
return [
'per_page' => [
'example' => 50,
'description' => 'The number of items to show per page',
],
'page' => [
'example' => 1,
'description' => 'The current page of items to display',
],
'title' => ['example' => null, 'description' => 'Filter map layers by title'],
'sort_by' => ['example' => 'title'],
'sort_direction' => ['example' => 'asc'],
];
}
}
2 changes: 1 addition & 1 deletion app-modules/api/src/Resources/ApiResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ protected function getId($resource)
return $resource->id;
}

private function isRunningScribe()
protected function isRunningScribe()
{
$args = [];

Expand Down
10 changes: 10 additions & 0 deletions app-modules/api/src/Resources/MapLayers/V1/MapLayerCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace HackGreenville\Api\Resources\MapLayers\V1;

use Illuminate\Http\Resources\Json\ResourceCollection;

class MapLayerCollection extends ResourceCollection
{
public $collects = MapLayerResource::class;
}
56 changes: 56 additions & 0 deletions app-modules/api/src/Resources/MapLayers/V1/MapLayerResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace HackGreenville\Api\Resources\MapLayers\V1;

use App\Models\MapLayer;
use HackGreenville\Api\Resources\ApiResource;
use Illuminate\Http\Request;

class MapLayerResource extends ApiResource
{
/** @var MapLayer $resource */
public $resource;

public function toArray(Request $request): array
{
return [
'id' => $this->getId($this->resource),
'title' => $this->resource->title,
'slug' => $this->resource->slug,
'description' => $this->resource->description,
'center_latitude' => (float) $this->resource->center_latitude,
'center_longitude' => (float) $this->resource->center_longitude,
'zoom_level' => $this->resource->zoom_level,
'geojson_link' => $this->resource->geojson_link,
'geojson_url' => $this->getGeoJsonUrl(),
'contribute_link' => $this->resource->contribute_link,
'raw_data_link' => $this->resource->raw_data_link,
'maintainers' => $this->resource->maintainers ?? [],
'created_at' => $this->resource->created_at->toISOString(),
'updated_at' => $this->resource->updated_at->toISOString(),
];
}

public function with(Request $request): array
{
return [
'meta' => [
'version' => '1.0',
'timestamp' => $this->getTime(),
],
];
}

private function getGeoJsonUrl(): ?string
{
if ( ! $this->resource->slug) {
return null;
}

if ($this->isRunningScribe()) {
return config('app.url') . "/api/v1/map-layers/{$this->resource->slug}/geojson";
}

return route('api.v1.map-layers.geojson', ['mapLayer' => $this->resource->slug]);
}
}
Loading
Loading