diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5f3bf58 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +GOOGLE_APPLICATION_CREDENTIALS=gcp-credentials.json +BIGQUERY_PROJECT_ID=your-GCP-project-id +GCS_BUCKET_NAME=your-GCS-bucket-name +AUTH_API_ACCESS_KEY=create-one-from-TheAuthAPI.com \ No newline at end of file diff --git a/.env.sample b/.env.sample deleted file mode 100644 index 7f3fbfa..0000000 --- a/.env.sample +++ /dev/null @@ -1,4 +0,0 @@ -GOOGLE_APPLICATION_CREDENTIALS=gcp-credentials.json -BIGQUERY_PROJECT_ID=thatapiplatform -GCS_BUCKET_NAME=overture-maps-query-cache -AUTH_API_ACCESS_KEY=create-one-from-theauthapi.com \ No newline at end of file diff --git a/README.md b/README.md index 7520fc6..376151a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ -## Description +# Overture Maps API -[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. +- Built using [NestJS](https://github.com/nestjs/nest) TypeScript Framework ## Endpoints +- [OpenAPI Spec Doc](https://overture-maps-api.thatapicompany.com/api-docs.json) - [./Places](https://docs.overturemaps.org/guides/places/) - The Overture places theme has one feature type, called place, and contains more than 53 million point representations of real-world entities: schools, businesses, hospitals, religious organizations, landmarks, mountain peaks, and much more. -- [./Places/Brands] -- [./Places/Categories] -- [./Places/Countries] +- ./Places/Brands - Lists all the Brands used in the Places data, along with counts of Places for each. +- ./Places/Categories - Lists all the Categories used in the Places data, along with counts of Places and Brands in each +- ./Places/Countries - Lists all the Countries used in the Places data, along with counts of Places and Brands in each ### Schemas & Design @@ -15,50 +16,64 @@ - [Response Formats GeoJSON & JSON](./docs/response-formats.md) - [Place](https://docs.overturemaps.org/schema/reference/places/place/) - [Address](https://docs.overturemaps.org/schema/reference/addresses/address/) +- [Overture Maps Official site](https://overturemaps.org/) +- [Overture Maps API](https://docs.overturemaps.org/) -### Extras +### API Roadmap -- [Overture Maps](https://overturemaps.org/) -- [Overture Maps API](https://docs.overturemaps.org/) +- [x] Places endpoint for Overture Maps 'Place' Theme +- [x] Places/Brands endpoint +- [x] Places/Categories endpoint +- [x] Places/Countries endpoint +- [ ] Places/Buildings endpoint +- [ ] Addresses endpoint for Overture Maps 'Address' Theme +- [ ] Base endpoint for Overture Maps 'Base' Theme +- [ ] Buildings endpoint for Overture Maps 'Building' Theme +- [ ] Transportation endpoint for Overture Maps 'Transportation' Theme +- [ ] Divisions endpoint for Overture Maps 'Division' Theme -### Data patching +Extras: -- Wikidata ID - is not always availble in the Overture Maps data. We can use the Wikidata API to get the wikidata_id for the place with a name and country match for best quess. This can be disabled in the request parameters via `patch_wikidata=false`. +- [ ] Fill `wikidata` holes in the data +- [ ] Add `wikidata` to the appropriate response for things like Brand logos, and more info ### Deployment & Datasets - [Google Cloud Platform](./docs/google-cloud-platform.md) - ### API Key management -You can either use the hardcoded API key in the code, or use the Auth API by going to theAuthAPI.com and creating an account. You can then create an Access Key for the App and add it as an Env var, and then create any number of API Keys for secure access to the API, and rate-limit them for cost control. - +You can either use the hardcoded API key in the code `DEMO-API-KEY`, or use the Auth API by going to theAuthAPI.com and creating an account. You can then create an Access Key for the App and add it as an Env var, and then create any number of API Keys for secure access to the API, and rate-limit them for cost control. ### Running Locally -GCP: Download the Service Account .json file, and set the name in the .env variable `GOOGLE_APPLICATION_CREDENTIALS` to the path of the file. +- GCP: setup a key as per the [GCP guide](./docs/google-cloud-platform.md), then download the Service Account .json file locally, and set the name in the `.env` variable `GOOGLE_APPLICATION_CREDENTIALS` to the path of the file. ```bash npm install + npm run test npm run start ``` Test the API by curl on `http://localhost:8080/places/countries` with the DEMO-API-KEY ```bash -curl -H "x-api-key: DEMO-API-KEY" -X GET -G 'http://localhost:8080/places/brands' -d 'country=AU' +curl -H "x-api-key: DEMO-API-KEY" -X GET -G 'http://localhost:8080/places/brands' \ +-d 'country=AU' ``` -```bash -curl -H "x-api-key: DEMO-API-KEY" -X GET -G 'http://localhost:8080/places/brands' -d 'country=AU' -d 'category=adult_store' -``` +To get GeoJSON format, add `format=geojson` to the query string ```bash -curl -H "x-api-key: DEMO-API-KEY" -X GET -G 'http://localhost:8080/places' -d 'country=AU' -d 'brand_name=TAB' -d 'limit=2' -d 'format=geojson' +curl -H "x-api-key: DEMO-API-KEY" -X GET -G 'http://localhost:8080/places' \ +-d 'country=AU' -d 'brand_name=TAB' -d 'limit=2' -d 'format=geojson' ``` +To get the Categories or Brands filtered by Category for a Country + ```bash -curl -H "x-api-key: DEMO-API-KEY" -X GET -G 'http://localhost:8080/places/categories' -d 'country=AU' -curl -H "x-api-key: DEMO-API-KEY" -X GET -G 'http://localhost:8080/places/brands' -d 'country=AU' -d 'categories=adult_store,airlines,airline' -``` \ No newline at end of file +curl -H "x-api-key: DEMO-API-KEY" -X GET -G 'http://localhost:8080/places/categories' \ +-d 'country=AU' +curl -H "x-api-key: DEMO-API-KEY" -X GET -G 'http://localhost:8080/places/brands' \ +-d 'country=AU' -d 'categories=airlines,airline' +``` diff --git a/package-lock.json b/package-lock.json index 980d180..0783f48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "overture-maps-api", - "version": "0.0.1", + "version": "0.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "overture-maps-api", - "version": "0.0.1", + "version": "0.0.6", "license": "UNLICENSED", "dependencies": { "@google-cloud/bigquery": "^7.9.1", @@ -15,11 +15,13 @@ "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^8.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.5", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1", "theauthapi": "^1.0.1-2.1" }, "devDependencies": { @@ -1880,6 +1882,11 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz", + "integrity": "sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA==" + }, "node_modules/@nestjs/cli": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", @@ -2093,6 +2100,25 @@ } } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", + "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/platform-express": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.5.tgz", @@ -2199,6 +2225,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@nestjs/swagger": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-8.0.1.tgz", + "integrity": "sha512-kW0dlsZXXWQgSSJHvk0fzg6kHvLcJ6trpbfvj5UN8DWIyCdCS/MGNshDE3P82xxKcg/pLZH7z41qYpFiawkGvQ==", + "dependencies": { + "@microsoft/tsdoc": "^0.15.0", + "@nestjs/mapped-types": "2.0.5", + "js-yaml": "4.1.0", + "lodash": "4.17.21", + "path-to-regexp": "3.3.0", + "swagger-ui-dist": "5.17.14" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.5.tgz", @@ -3270,7 +3328,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-flatten": { @@ -7203,7 +7260,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -9246,6 +9302,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", + "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==" + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", diff --git a/package.json b/package.json index c56517f..f264a8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "overture-maps-api", - "version": "0.0.6", + "version": "0.0.7", "description": "", "author": "", "private": true, @@ -26,11 +26,13 @@ "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^8.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.5", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1", "theauthapi": "^1.0.1-2.1" }, "devDependencies": { diff --git a/src/main.ts b/src/main.ts index b733a9b..4db192d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import * as bodyParser from 'body-parser'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -26,6 +27,29 @@ async function bootstrap() { } app.enableCors(corsOptions); app.useGlobalPipes(new ValidationPipe({transform: true})); + + + // Configure Swagger + const config = new DocumentBuilder() + .setTitle('Overture Maps API Documentation') + .setDescription('Auto-generated OpenAPI spec for the Overture Maps API') + .setVersion('1.0') + .addServer('http://localhost:8080/', 'Local environment') + .addServer('https://overture-maps-api.thatapicompany.com','Cloud API Service') + .setContact("Aden Forshaw", "https://thatapicompany.com/overure-maps-api", "aden@thatapicompany.com") + .addApiKey( + { type: 'apiKey', name: 'x-api-key', in: 'header' }, + 'API_KEY', // Reference name for the security scheme + ) + .addTag('places', 'Operations related to Places') + .build(); + + // Create the Swagger document + const document = SwaggerModule.createDocument(app, config); + // Serve the Swagger document at /api-docs + SwaggerModule.setup('api-docs', app, document,{jsonDocumentUrl: '/api-docs-json', swaggerUrl: '/api-docs-ui'}); + + await app.listen(8080); } bootstrap(); diff --git a/src/middleware/auth-api.middleware.ts b/src/middleware/auth-api.middleware.ts index 1d01991..78910cd 100644 --- a/src/middleware/auth-api.middleware.ts +++ b/src/middleware/auth-api.middleware.ts @@ -47,7 +47,6 @@ export class AuthAPIMiddleware implements NestMiddleware { const apiKeyString = this.getAPIKeyFromHeaderOrQuery(req); - this.logger.log(`API Key: ${apiKeyString}`); //if no api key, or user is already set, skip if (!apiKeyString || req.res.locals['user']?.id ) { next(); diff --git a/src/places/dto/get-brands.dto.ts b/src/places/dto/get-brands.dto.ts deleted file mode 100644 index 8a6bbe6..0000000 --- a/src/places/dto/get-brands.dto.ts +++ /dev/null @@ -1,31 +0,0 @@ -// src/places/dto/get-brands.dto.ts -import { Transform } from 'class-transformer'; -import { IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, ValidateIf } from 'class-validator'; - -export class GetBrandsDto { - @IsOptional() - @IsString() - @MaxLength(2) - @MinLength(2) - country?: string; // ISO 3166 country code - - @ValidateIf(o => !o.country) - @IsNumber() - lat?: number; - - @ValidateIf(o => !o.country) - @IsNumber() - lng?: number; - - @ValidateIf(o => !o.country) - @IsOptional() - @IsNumber() - @Min(1) - radius?: number = 1000; // Default radius is 1000 meters if not provided - - //transform into an array of strings - @IsOptional() - @Transform(({ value }) => String(value).split(',')) - @IsString({ each: true }) - categories?: string[]; // Array of category names -} \ No newline at end of file diff --git a/src/places/dto/get-categories.dto.ts b/src/places/dto/get-categories.dto.ts deleted file mode 100644 index 18057aa..0000000 --- a/src/places/dto/get-categories.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -// src/places/dto/get-brands.dto.ts -import { Transform } from 'class-transformer'; -import { IsNumber, IsOptional, IsString, Max, MaxLength, Min, MinLength, ValidateIf } from 'class-validator'; - -export class GetCategoriesDto { - @IsOptional() - @IsString() - @MaxLength(2) - @MinLength(2) - country?: string; // ISO 3166 country code - -} \ No newline at end of file diff --git a/src/places/dto/get-places.dto.ts b/src/places/dto/get-places.dto.ts deleted file mode 100644 index 16018aa..0000000 --- a/src/places/dto/get-places.dto.ts +++ /dev/null @@ -1,59 +0,0 @@ -// src/places/dto/get-places.dto.ts -import { Transform } from 'class-transformer'; -import { IsIn, IsNumber, IsOptional, IsString, Min, ValidateIf } from 'class-validator'; - -export class GetPlacesDto { - //convert string to number - @ValidateIf(o => !o.country) - @Transform(({ value }) => parseFloat(value)) - @IsNumber() - lat: number; - - @ValidateIf(o => !o.country) - @Transform(({ value }) => parseFloat(value)) - @IsNumber() - lng: number; - - @ValidateIf(o => !o.country) - @IsOptional() - @Transform(({ value }) => parseFloat(value)) - @IsNumber() - @Min(1) - radius?: number = 1000; // Default radius is 1000 meters if not provided - - - @IsOptional() - @Transform(({ value }) => parseInt(value)) - @IsNumber() - @Min(1) - limit?: number = 100; // Default limit is 10 if not provided - - @IsOptional() - @IsString() - country?: string; // ISO 3166 country code - - @IsOptional() - @IsString() - brand_wikidata?: string; // Wikidata brand ID - - @IsOptional() - @IsString() - brand_name?: string; // Wikidata brand ID - - @IsOptional() - @Transform(({ value }) => parseFloat(value)) - min_confidence?: number = 0.5; - - - //transform into an array of strings - @IsOptional() - @Transform(({ value }) => String(value).split(',')) - @IsString({ each: true }) - categories?: string[]; // Array of category names - - //json by default csv or geojson - @IsOptional() - @IsString() - @IsIn(['json', 'csv', 'geojson']) - format?: string = 'json'; -} diff --git a/src/places/dto/models/brand.dto.ts b/src/places/dto/models/brand.dto.ts new file mode 100644 index 0000000..b0348f8 --- /dev/null +++ b/src/places/dto/models/brand.dto.ts @@ -0,0 +1,40 @@ + +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Brand, Place } from '../../interfaces/place.interface'; + + +export class BrandRulesDto { + @ApiProperty({ description: 'Variant of the rule.', example: 'Abbreviation' }) + variant: string; + + @ApiProperty({ description: 'Value associated with the rule.', example: 'CP' }) + value: string; +} + +export class BrandNamesDto { + @ApiProperty({ description: 'Primary name of the place.', example: 'Central Park' }) + primary: string; + + @ApiPropertyOptional({ description: 'Common names in different languages.', type: 'object', example: { en: 'Central Park', es: 'Parque Central' } ,properties: {}}) + common?: Record; + + @ApiPropertyOptional({ + description: 'Naming rules or variants associated with the place.', + type: [BrandRulesDto], + }) + rules?: BrandRulesDto[]; +} + + +export class BrandDto { + @ApiProperty({ + description: 'Names associated with the brand, usually Primary is the most useful', + type: () => BrandNamesDto, + }) + names: BrandNamesDto; + + + constructor(data: Brand) { + Object.assign(this, data); + } +} diff --git a/src/places/dto/models/category.dto.ts b/src/places/dto/models/category.dto.ts new file mode 100644 index 0000000..cf7d96f --- /dev/null +++ b/src/places/dto/models/category.dto.ts @@ -0,0 +1,13 @@ + +import { ApiProperty } from '@nestjs/swagger'; + +export class CategoryDto { + @ApiProperty({ description: 'Primary category of the place.', example: 'Retail' }) + primary: string; + + constructor(data) { + + Object.assign(this, data); + + } +} diff --git a/src/places/dto/place-response.dto.ts b/src/places/dto/place-response.dto.ts index ffcb2ff..c3b2691 100644 --- a/src/places/dto/place-response.dto.ts +++ b/src/places/dto/place-response.dto.ts @@ -1,82 +1,162 @@ -// src/places/dto/place-response.dto.ts +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Place } from '../interfaces/place.interface'; +import { BrandDto } from './models/brand.dto'; +import { CategoryDto } from './models/category.dto'; + +export class RulesDto { + @ApiProperty({ description: 'Variant of the rule.', example: 'Abbreviation' }) + variant: string; + + @ApiProperty({ description: 'Value associated with the rule.', example: 'CP' }) + value: string; +} +export class SourceDto { + @ApiProperty({ description: 'Source property name.', example: 'OpenStreetMap' }) + property: string; + + @ApiProperty({ description: 'Dataset source for the place.', example: 'OSM' }) + dataset: string; + + @ApiProperty({ description: 'Unique identifier for the record in the dataset.', example: 'osm12345' }) + record_id: string; +} + +export class PlaceNamesDto { + @ApiProperty({ description: 'Primary name of the place.', example: 'Central Park' }) + primary: string; + + @ApiPropertyOptional({ description: 'Common names in different languages.', type: 'object', example: { en: 'Central Park', es: 'Parque Central' } , properties: {}}) + common?: Record; + + @ApiPropertyOptional({ + description: 'Naming rules or variants associated with the place.', + type: [RulesDto], + }) + rules?: RulesDto[]; +} +export class AddressDto { + @ApiPropertyOptional({ description: 'Full freeform address of the place.', example: '123 Main St, Springfield' }) + freeform?: string; + + @ApiPropertyOptional({ description: 'Locality or city name.', example: 'Springfield' }) + locality?: string; + + @ApiPropertyOptional({ description: 'Region or state name.', example: 'Illinois' }) + region?: string; + + @ApiPropertyOptional({ description: 'Country name.', example: 'United States' }) + country?: string; +} + +export class GeometryDto { + @ApiProperty({ description: 'Type of geometry', example: 'Point' }) + type: string; + + @ApiProperty({ + description: 'Coordinates representing the geometry.', + example: [40.7128, -74.0060], + type: [Number], + }) + coordinates: number[]; +} + +export class PropertiesDto { + @ApiProperty({ description: 'Primary category of the place.', type: () => CategoryDto }) + categories: CategoryDto; + + @ApiPropertyOptional({ description: 'Confidence score of the place.', example: 0.8 }) + confidence?: number; + + @ApiPropertyOptional({ description: 'Websites associated with the place.', type: [String] }) + websites?: string[]; + + @ApiPropertyOptional({ description: 'Emails associated with the place.', type: [String] }) + emails?: string[]; + + @ApiPropertyOptional({ description: 'Social media links associated with the place.', type: [String] }) + socials?: string[]; + + @ApiPropertyOptional({ description: 'Phone numbers associated with the place.', type: [String] }) + phones?: string[]; + + @ApiPropertyOptional({ + description: 'Brand details if applicable.', + type: () => BrandDto, + }) + brand?: BrandDto; + + @ApiPropertyOptional({ + description: 'Address information of the place.', + type: [AddressDto], + }) + addresses?: AddressDto[]; + + @ApiProperty({ description: 'Theme associated with the place.', example: 'Restaurant' }) + theme: string; + + @ApiProperty({ description: 'Type of feature or place.', example: 'Commercial' }) + type: string; + + @ApiProperty({ description: 'Version number of the place data.', example: 1 }) + version: number; + + @ApiProperty({ + description: 'Source information for the place data.', + type: [SourceDto], + }) + sources: SourceDto[]; + + @ApiProperty({ + description: 'Name details for the place.', + type: () => PlaceNamesDto, + }) + names: PlaceNamesDto; +} export class PlaceResponseDto { + @ApiProperty({ description: 'Unique identifier of the place.', example: '12345' }) id: string; + + @ApiProperty({ description: 'Type of place or feature.', example: 'Point of Interest' }) type: string; - geometry: { - type: string; - coordinates: number[]; - }; - properties: { - categories: { - primary: string; - }; - confidence?: number; - websites?: string[]; - emails?: string[]; - socials?: string[]; - phones?: string[]; - brand?: { - names: { - primary: string; - }; - wikidata?: string; - }; - addresses?: { - freeform?: string; - locality?: string; - region?: string; - country?: string; - }[]; - theme: string; - type: string; - version: number; - sources: { - property: string; - dataset: string; - record_id: string; - }[]; - names: { - primary: string; - common?: Record; - rules?: { - variant: string; - value: string; - }[]; - }; - }; + + @ApiProperty({ + description: 'Geometric representation of the place.', + type: () => GeometryDto, + }) + geometry: GeometryDto; + + @ApiProperty({ + description: 'Properties and additional details of the place.', + type: () => PropertiesDto, + }) + properties: PropertiesDto; constructor(place: Place) { this.id = place.id; - // assign any values ofr place to this object Object.assign(this, place); - } } -export const toPlaceResponseDto = (place: Place) => { - return new PlaceResponseDto(place); -} export const toPlacesGeoJSONResponseDto = (places: Place[]) => { - const toplevel = - { - "type":"FeatureCollection", - "features":[ - ] - } - places.forEach(place => { - toplevel.features.push({ - "type":"Feature", - "geometry":place.geometry, - "properties":{ - confidence: place.confidence, - ...place.names, - ...place.brand, - ...place.categories - } - }) - }) - return toplevel; -} \ No newline at end of file + const toplevel = + { + "type":"FeatureCollection", + "features":[ + ] + } + places.forEach(place => { + toplevel.features.push({ + "type":"Feature", + "geometry":place.geometry, + "properties":{ + confidence: place.confidence, + ...place.names, + ...place.brand, + ...place.categories + } + }) + }) + return toplevel; + } \ No newline at end of file diff --git a/src/places/dto/requests/get-brands.dto.ts b/src/places/dto/requests/get-brands.dto.ts new file mode 100644 index 0000000..c078085 --- /dev/null +++ b/src/places/dto/requests/get-brands.dto.ts @@ -0,0 +1,55 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsNumber, IsOptional, IsString, MaxLength, Min, MinLength, ValidateIf } from 'class-validator'; + +export class GetBrandsDto { + @ApiPropertyOptional({ + description: 'ISO 3166 country code consisting of 2 characters. Required if lat/lng are not provided.', + example: 'US', + maxLength: 2, + minLength: 2, + }) + @IsOptional() + @IsString() + @MaxLength(2) + @MinLength(2) + country?: string; // ISO 3166 country code + + @ApiPropertyOptional({ + description: 'Latitude coordinate. Required if country code is not provided.', + example: 40.7128, + }) + @ValidateIf(o => !o.country) + @IsNumber() + lat?: number; + + @ApiPropertyOptional({ + description: 'Longitude coordinate. Required if country code is not provided.', + example: -74.0060, + }) + @ValidateIf(o => !o.country) + @IsNumber() + lng?: number; + + @ApiPropertyOptional({ + description: 'Search radius in meters, defaulting to 1000 meters if not provided.', + example: 1000, + minimum: 1, + default: 1000, + }) + @ValidateIf(o => !o.country) + @IsOptional() + @IsNumber() + @Min(1) + radius?: number = 1000; // Default radius is 1000 meters if not provided + + @ApiPropertyOptional({ + description: 'Array of category names, provided as a comma-separated string.', + example: ['food', 'retail'], + type: [String], + }) + @IsOptional() + @Transform(({ value }) => String(value).split(',')) + @IsString({ each: true }) + categories?: string[]; // Array of category names +} diff --git a/src/places/dto/requests/get-categories.dto.ts b/src/places/dto/requests/get-categories.dto.ts new file mode 100644 index 0000000..f4e31c8 --- /dev/null +++ b/src/places/dto/requests/get-categories.dto.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class GetCategoriesDto { + @ApiPropertyOptional({ + description: 'ISO 3166 country code consisting of 2 characters.', + example: 'US', + maxLength: 2, + minLength: 2, + }) + @IsOptional() + @IsString() + @MaxLength(2) + @MinLength(2) + country?: string; // ISO 3166 country code +} diff --git a/src/places/dto/requests/get-places.dto.ts b/src/places/dto/requests/get-places.dto.ts new file mode 100644 index 0000000..971753f --- /dev/null +++ b/src/places/dto/requests/get-places.dto.ts @@ -0,0 +1,102 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsArray, IsIn, IsNumber, IsOptional, IsString, Min, ValidateIf } from 'class-validator'; + +export class GetPlacesDto { + @ApiProperty({ + description: 'Latitude coordinate. Required if country code is not provided.', + example: 40.7128, + }) + @ValidateIf(o => !o.country) + @Transform(({ value }) => parseFloat(value)) + @IsNumber() + lat: number; + + @ApiProperty({ + description: 'Longitude coordinate. Required if country code is not provided.', + example: -74.0060, + }) + @ValidateIf(o => !o.country) + @Transform(({ value }) => parseFloat(value)) + @IsNumber() + lng: number; + + @ApiPropertyOptional({ + description: 'Search radius in meters, defaulting to 1000 meters if not provided.', + example: 1000, + minimum: 1, + default: 1000, + }) + @ValidateIf(o => !o.country) + @IsOptional() + @Transform(({ value }) => parseFloat(value)) + @IsNumber() + @Min(1) + radius?: number = 1000; + + @ApiPropertyOptional({ + description: 'ISO 3166 country code consisting of 2 characters. Required if lat/lng are not provided.', + example: 'US', + }) + @IsOptional() + @IsString() + country?: string; + + + @ApiPropertyOptional({ + description: 'Limit on the number of results returned, defaulting to 100 if not provided.', + example: 10, + minimum: 1, + default: 100, + }) + @IsOptional() + @Transform(({ value }) => parseInt(value)) + @IsNumber() + @Min(1) + limit?: number = 100; + + @ApiPropertyOptional({ + description: 'Wikidata brand ID associated with the place.', + example: 'Q12345', + }) + @IsOptional() + @IsString() + brand_wikidata?: string; + + @ApiPropertyOptional({ + description: 'Brand name associated with the place.', + example: 'Starbucks', + }) + @IsOptional() + @IsString() + brand_name?: string; + + @ApiPropertyOptional({ + description: 'Minimum confidence score for the places to be returned, defaulting to 0.5 if not provided.', + example: 0.5, + default: 0.5, + }) + @IsOptional() + @Transform(({ value }) => parseFloat(value)) + min_confidence?: number = 0.5; + + @ApiPropertyOptional({ + description: 'Array of category names, provided as a comma-separated string.', + example: 'food,retail', + type: String + }) + @IsOptional() + @Transform((params) => String(params.value).split(',').map(String)) + categories?: string[]; + + @ApiPropertyOptional({ + description: 'Response format, defaulting to JSON. Options are "json", "csv", or "geojson".', + example: 'json', + default: 'json', + enum: ['json', 'csv', 'geojson'], + }) + @IsOptional() + @IsString() + @IsIn(['json', 'csv', 'geojson']) + format?: string = 'json'; +} diff --git a/src/places/dto/responses/brand-response.dto.ts b/src/places/dto/responses/brand-response.dto.ts new file mode 100644 index 0000000..61cf4cf --- /dev/null +++ b/src/places/dto/responses/brand-response.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { BrandDto } from "../models/brand.dto"; + +export class BrandCountsDto { + @ApiProperty({ + description: 'Number of places where the brand is found', + example: 100, + }) + places: number; + } +export class BrandResponseDto extends BrandDto { + + @ApiProperty({ + description: 'Counts related to the brand e.g. how many Places are associated with it', + type: () => BrandCountsDto, + }) + ext_counts: BrandCountsDto; + + } + + export const toBrandResponseDto = (data) => { + return new BrandResponseDto(data); + } + \ No newline at end of file diff --git a/src/places/dto/responses/category-response.dto.ts b/src/places/dto/responses/category-response.dto.ts new file mode 100644 index 0000000..351affb --- /dev/null +++ b/src/places/dto/responses/category-response.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { CategoryDto } from "../models/category.dto"; + +export class CategoryCountsDto { + @ApiProperty({ + description: 'Number of places with this Category', + example: 100, + }) + places: number; + + @ApiProperty({ + description: 'Number of brands that are associated with the Categorys', + example: 10, + }) + brands: number; + } +export class CategoryResponseDto extends CategoryDto { + @ApiProperty({ + description: 'Counts related to the Category e.g. how many Places and Brands are associated with it', + type: () => CategoryCountsDto, + }) + ext_counts: { + places: number; + brands: number; + } + + }; + export const toCategoryResponseDto = (data) => { + return new CategoryResponseDto(data); + } + \ No newline at end of file diff --git a/src/places/dto/responses/country-response.dto.ts b/src/places/dto/responses/country-response.dto.ts new file mode 100644 index 0000000..4c9cdd2 --- /dev/null +++ b/src/places/dto/responses/country-response.dto.ts @@ -0,0 +1,40 @@ + +import { ApiProperty } from '@nestjs/swagger'; +import { Brand, Place } from '../../interfaces/place.interface'; +export class CountryCountsDto { + @ApiProperty({ + description: 'Number of places in this Country', + example: 100, + }) + places: number; + + @ApiProperty({ + description: 'Number of brands that are associated with the Country', + example: 10, + }) + brands: number; +} + +export class CountryResponseDto { + @ApiProperty({ description: 'The ISO code of the Country.', example: 'US' }) + country: string; + + @ApiProperty({ + description: 'Counts related to the Country e.g. how many Places and Brands are associated with it', + type: () => CountryCountsDto, + }) + ext_counts: { + places: number; + brands: number; + } + + constructor(data: Brand) { + + Object.assign(this, data); + + } +}; + +export const toCountryResponseDto = (data) => { + return new CountryResponseDto(data); +} diff --git a/src/places/places.controller.ts b/src/places/places.controller.ts index 55a1ac5..d46fa03 100644 --- a/src/places/places.controller.ts +++ b/src/places/places.controller.ts @@ -2,12 +2,18 @@ import { Controller, Get, Logger, Query, UseGuards } from '@nestjs/common'; import { BigQueryService } from '../bigquery/bigquery.service'; import { GcsService } from '../gcs/gcs.service'; -import { GetPlacesDto } from './dto/get-places.dto'; +import { GetPlacesDto } from './dto/requests/get-places.dto'; import { PlaceResponseDto, toPlacesGeoJSONResponseDto } from './dto/place-response.dto'; -import { GetBrandsDto } from './dto/get-brands.dto'; +import { GetBrandsDto } from './dto/requests/get-brands.dto'; import { IsAuthenticatedGuard } from '../guards/is-authenticated.guard'; -import { GetCategoriesDto } from './dto/get-categories.dto'; +import { GetCategoriesDto } from './dto/requests/get-categories.dto'; +import { ApiTags, ApiOperation, ApiResponse, ApiBody , ApiQuery, ApiSecurity} from '@nestjs/swagger'; +import { BrandDto } from './dto/models/brand.dto'; +import { CountryResponseDto, toCountryResponseDto } from './dto/responses/country-response.dto'; +import { CategoryResponseDto, toCategoryResponseDto } from './dto/responses/category-response.dto'; +@ApiTags('places') +@ApiSecurity('API_KEY') // Applies the API key security scheme defined in Swagger @Controller('places') @UseGuards(IsAuthenticatedGuard) export class PlacesController { @@ -20,6 +26,9 @@ export class PlacesController { ) {} @Get() + @ApiOperation({ summary: 'Get all Places using Query params as filters' }) + @ApiResponse({ status: 200, description: 'Return all Places.' , type: PlaceResponseDto, isArray: true}) + @ApiQuery({type:GetPlacesDto}) async getPlaces(@Query() query: GetPlacesDto) { const { lat, lng, radius, country, min_confidence, brand_wikidata,brand_name,categories,limit } = query; @@ -36,6 +45,8 @@ export class PlacesController { // Cache the results in GCS await this.gcsService.storeJSON (results,cacheKey); + + return results.map((place: any) => new PlaceResponseDto(place)); } @@ -46,6 +57,9 @@ export class PlacesController { } @Get('brands') + @ApiOperation({ summary: 'Get all Brands from Places using Query params as filters' }) + @ApiResponse({ status: 200, description: 'Return all Brands, along with a count of all Places for each.' , type: BrandDto, isArray: true}) + @ApiQuery({type:GetBrandsDto}) async getBrands(@Query() query: GetBrandsDto) { const { country, lat, lng, radius, categories } = query; @@ -58,9 +72,11 @@ export class PlacesController { const results = await this.bigQueryService.getBrandsNearby(country, lat, lng, radius, categories); await this.gcsService.storeJSON (results,cacheKey); - return results; + return results.map((brand: any) => new BrandDto(brand)); } @Get('countries') + @ApiOperation({ summary: 'Get all Countries from Places using Query params as filters' }) + @ApiResponse({ status: 200, description: 'Return all Countries, as well as a count of all the Places and Brands in each.', type:CountryResponseDto, isArray: true}) async getCountries() { const cacheKey = `get-places-countries`; const cachedResult = await this.getFromCache(cacheKey); @@ -70,10 +86,13 @@ export class PlacesController { const results = await this.bigQueryService.getPlaceCountsByCountry(); await this.gcsService.storeJSON (results,cacheKey); - return results; + return results.map((country: any) => toCountryResponseDto(country)); } @Get('categories') + @ApiOperation({ summary: 'Get all Categories from Places using Query params as filters' }) + @ApiResponse({ status: 200, description: 'Return all Categories, along with a count of all Brands and Places for each' , type: CategoryResponseDto, isArray: true}) + @ApiQuery({type:GetCategoriesDto}) async getCategories(@Query() query: GetCategoriesDto) { const cacheKey = `get-places-categories-${JSON.stringify(query)}`; @@ -85,7 +104,7 @@ export class PlacesController { const results = await this.bigQueryService.getCategories(query.country); await this.storeToCache (results, cacheKey); - return results; + return results.map((category: any) => toCategoryResponseDto(category)); } async getFromCache(cacheKey:string): Promise {