Skip to content

Commit 893bead

Browse files
Merge pull request #53 from fityannugroho/feat/island
v1.1.0: Create island endpoints 🏝️
2 parents 16e15ab + 4a81e4f commit 893bead

20 files changed

+597
-7
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ The following is the list of supported scopes:
7272
- `regency`: for changes made on `src/regency` directory.
7373
- `district`: for changes made on `src/district` directory.
7474
- `village`: for changes made on `src/village` directory.
75+
- `island`: for changes made on `src/island` file.
7576

7677
If your change affect more than one package, separate the scopes with a comma (e.g. `test,province`).
7778

README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ API that provides information on the **administrative areas of Indonesia**, from
1111

1212
Built with [NestJS framework](https://nestjs.com) and writen in TypeScript. [Prisma](https://www.prisma.io) is used as the ORM to interact with any kind of database (in future). For now, we use MongoDB.
1313

14+
> **NEW!** [Island endpoints 🏝️](#12-get-islands-by-name) available in version 1.2.0 or higher.
15+
1416
<h2>Table of Content</h2>
1517

1618
- [Getting Started](#getting-started)
@@ -24,11 +26,14 @@ Built with [NestJS framework](https://nestjs.com) and writen in TypeScript. [Pri
2426
- [4. Get Regencies by Name](#4-get-regencies-by-name)
2527
- [5. Get Specific Regency](#5-get-specific-regency)
2628
- [6. Get All Districts in a Regency](#6-get-all-districts-in-a-regency)
29+
- [Get All Islands in a Regency](#get-all-islands-in-a-regency)
2730
- [7. Get Districts by Name](#7-get-districts-by-name)
2831
- [8. Get Specific District](#8-get-specific-district)
2932
- [9. Get All Villages in a District](#9-get-all-villages-in-a-district)
3033
- [10. Get Villages by Name](#10-get-villages-by-name)
3134
- [11. Get Specific Village](#11-get-specific-village)
35+
- [12. Get Islands by Name](#12-get-islands-by-name)
36+
- [13. Get Specific Island](#13-get-specific-island)
3237
- [Query Parameters](#query-parameters)
3338
- [`sortBy`](#sortby)
3439
- [`sortOrder`](#sortorder)
@@ -149,6 +154,19 @@ GET /regencies/{regencyCode}/districts
149154

150155
> This endpoint also support [`sortBy`][sortby-query] and [`sortOrder`][sortorder-query] queries.
151156
157+
### Get All Islands in a Regency
158+
159+
```
160+
GET /regencies/{regencyCode}/islands
161+
```
162+
163+
- Use this endpoint to **get all islands in a regency**.
164+
- The `{regencyCode}` must be **4 numeric characters**. If not, you will get `400 Bad Request` response.
165+
- This endpoint **will return** the array of island if the `{regencyCode}` is exists. Otherwise, you will get a `404 Not Found` response.
166+
- Usage example: http://localhost:3000/regencies/1101/islands
167+
168+
> This endpoint also support [`sortBy`][sortby-query] and [`sortOrder`][sortorder-query] queries.
169+
152170
### 7. Get Districts by Name
153171

154172
```
@@ -210,6 +228,30 @@ GET /villages/{villageCode}
210228
- This endpoint **will return** the village with the same code as `{villageCode}`. Otherwise, you will get a `404 Not Found` response.
211229
- Usage example: http://localhost:3000/villages/3273111004
212230

231+
### 12. Get Islands by Name
232+
233+
```
234+
GET /islands?name={islandName}
235+
```
236+
237+
- Use this endpoint to **get the islands by its name**.
238+
- The `{islandName}` **is required** and must be **at least 3 characters**, maximum 255 characters, and does not contains any other symbols besides `'-/`. If not, you will get `400 Bad Request` response.
239+
- This endpoint **will return** an array of island, or an **empty array** `[]` if there are no island matched with the `{islandName}`.
240+
- Usage example: http://localhost:3000/islands?name=java
241+
242+
> This endpoint also support [`sortBy`][sortby-query] and [`sortOrder`][sortorder-query] queries.
243+
244+
### 13. Get Specific Island
245+
246+
```
247+
GET /islands/{islandCode}
248+
```
249+
250+
- Use this endpoint to **get a specific island**.
251+
- The `{islandCode}` must be **9 numeric characters**. If not, you will get `400 Bad Request` response.
252+
- This endpoint **will return** the island with the same code as `{islandCode}`. Otherwise, you will get a `404 Not Found` response.
253+
- Usage example: http://localhost:3000/islands/110140001
254+
213255
### Query Parameters
214256

215257
You can use query parameters to control what data is returned in endpoint responses.

prisma/schema.prisma

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ model District {
1818
@@map("districts")
1919
}
2020

21+
model Island {
22+
id String @id @default(auto()) @map("_id") @db.ObjectId
23+
code String @unique
24+
coordinate String
25+
isOutermostSmall Boolean @map("is_outermost_small")
26+
isPopulated Boolean @map("is_populated")
27+
name String
28+
regencyCode String? @map("regency_code")
29+
regency Regency? @relation(fields: [regencyCode], references: [code])
30+
31+
@@map("islands")
32+
}
33+
2134
model Province {
2235
id String @id @default(auto()) @map("_id") @db.ObjectId
2336
code String @unique
@@ -32,6 +45,7 @@ model Regency {
3245
code String @unique
3346
name String
3447
provinceCode String @map("province_code")
48+
islands Island[]
3549
districts District[]
3650
province Province @relation(fields: [provinceCode], references: [code])
3751

prisma/seed.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ const insertVillages = async () => {
2323
return await prisma.village.createMany({ data: villages });
2424
};
2525

26+
const insertIslands = async () => {
27+
const islands = await IdnArea.islands({ transform: true });
28+
return await prisma.island.createMany({ data: islands });
29+
};
30+
2631
/**
2732
* Delete all data in a collection.
2833
*/
@@ -59,6 +64,9 @@ const insertAreaData = async (collection: IdnArea.Areas) => {
5964
case 'villages':
6065
result = await insertVillages();
6166
break;
67+
case 'islands':
68+
result = await insertIslands();
69+
break;
6270
default:
6371
throw new Error('Invalid collection');
6472
}
@@ -70,11 +78,13 @@ const insertAreaData = async (collection: IdnArea.Areas) => {
7078
async function main() {
7179
await deleteAreaData('villages');
7280
await deleteAreaData('districts');
81+
await deleteAreaData('islands');
7382
await deleteAreaData('regencies');
7483
await deleteAreaData('provinces');
7584

7685
await insertAreaData('provinces');
7786
await insertAreaData('regencies');
87+
await insertAreaData('islands');
7888
await insertAreaData('districts');
7989
await insertAreaData('villages');
8090
}

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
22
import { ConfigModule } from '@nestjs/config';
33
import { AppController } from './app.controller';
44
import { DistrictModule } from './district/district.module';
5+
import { IslandModule } from './island/island.module';
56
import { ProvinceModule } from './province/province.module';
67
import { RegencyModule } from './regency/regency.module';
78
import { VillageModule } from './village/village.module';
@@ -13,6 +14,7 @@ import { VillageModule } from './village/village.module';
1314
RegencyModule,
1415
DistrictModule,
1516
VillageModule,
17+
IslandModule,
1618
],
1719
controllers: [AppController],
1820
providers: [],
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
export default class CoordinateConverter {
2+
/**
3+
* Check if the coordinate has valid DMS (degrees minutes seconds) format.
4+
*
5+
* Valid DMS format: `{a}°{b}'{c}" {y} {d}°{e}'{f}" {x}`
6+
* - `{a}` should be 2 digit integer from 00 to 90
7+
* - `{b}` should be 2 digit integer from 00 to 60
8+
* - `{c}` should be 2 digit integer with 2 decimal points from 00.00 to 60.00
9+
* - `{y}` should be N or S
10+
* - `{d}` should be 3 digit integer from 000 to 180
11+
* - `{e}` should be 2 digit integer from 00 to 60
12+
* - `{f}` should be 2 digit integer with 2 decimal points from 00.00 to 60.00
13+
* - `{x}` should be E or W
14+
*
15+
* Tested here: https://regex101.com/r/GQe8WT
16+
*/
17+
isValid(coordinate: string) {
18+
const regex =
19+
/^([0-8][0-9]|90)°([0-5][0-9]|60)'(([0-5][0-9].[0-9]{2})|60.00)"\s(N|S)\s(0\d{2}|1([0-7][0-9]|80))°([0-5][0-9]|60)'(([0-5][0-9].[0-9]{2})|60.00)"\s(E|W)$/;
20+
21+
return regex.test(coordinate);
22+
}
23+
24+
private calculate(
25+
degrees: string,
26+
minutes: string,
27+
seconds: string,
28+
pole: string,
29+
) {
30+
return (
31+
(parseFloat(degrees) +
32+
parseFloat(minutes) / 60 +
33+
parseFloat(seconds) / 3600) *
34+
(['N', 'E'].includes(pole) ? 1 : -1)
35+
);
36+
}
37+
38+
/**
39+
* Convert coordinate string to number.
40+
*
41+
* @throws Error if the coordinate is not in valid DMS (degrees minutes seconds) format
42+
*/
43+
convertToNumber(coordinate: string): number[] {
44+
if (!this.isValid(coordinate)) {
45+
throw new Error('Invalid coordinate format');
46+
}
47+
48+
const [a, b, c, d, e, f] = coordinate.match(/[0-9,\.]+/g);
49+
const [y, x] = coordinate.match(/(N|S|E|W)/g);
50+
51+
const latitude = this.calculate(a, b, c, y);
52+
const longitude = this.calculate(d, e, f, x);
53+
54+
return [latitude, longitude];
55+
}
56+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { IslandController } from './island.controller';
3+
import { IslandService } from './island.service';
4+
import { PrismaService } from '../common/services/prisma';
5+
6+
describe('IslandController', () => {
7+
let controller: IslandController;
8+
9+
beforeEach(async () => {
10+
const module: TestingModule = await Test.createTestingModule({
11+
controllers: [IslandController],
12+
providers: [IslandService, PrismaService],
13+
}).compile();
14+
15+
controller = module.get<IslandController>(IslandController);
16+
});
17+
18+
it('should be defined', () => {
19+
expect(controller).toBeDefined();
20+
});
21+
});

src/island/island.controller.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {
2+
Controller,
3+
Get,
4+
NotFoundException,
5+
Param,
6+
Query,
7+
} from '@nestjs/common';
8+
import { IslandService } from './island.service';
9+
import { IslandFindByCodeParams, IslandFindQueries } from './island.dto';
10+
import {
11+
ApiBadRequestResponse,
12+
ApiNotFoundResponse,
13+
ApiOkResponse,
14+
ApiOperation,
15+
ApiParam,
16+
ApiQuery,
17+
ApiTags,
18+
} from '@nestjs/swagger';
19+
import { Island } from '@prisma/client';
20+
21+
@ApiTags('Island')
22+
@Controller('islands')
23+
export class IslandController {
24+
constructor(private readonly islandService: IslandService) {}
25+
26+
@ApiOperation({
27+
description: 'Get the islands by its name.',
28+
})
29+
@ApiQuery({
30+
name: 'name',
31+
description: 'The island name.',
32+
required: true,
33+
type: 'string',
34+
example: 'sabang',
35+
})
36+
@ApiQuery({
37+
name: 'sortBy',
38+
description: 'Sort islands by its code, name, or coordinate.',
39+
required: false,
40+
type: 'string',
41+
example: 'code',
42+
})
43+
@ApiQuery({
44+
name: 'sortOrder',
45+
description: 'Sort islands in ascending or descending order.',
46+
required: false,
47+
type: 'string',
48+
example: 'asc',
49+
})
50+
@ApiOkResponse({ description: 'Returns array of islands.' })
51+
@ApiBadRequestResponse({ description: 'If there are invalid query values.' })
52+
@Get()
53+
async find(@Query() queries: IslandFindQueries) {
54+
const { name, sortBy, sortOrder } = queries ?? {};
55+
return this.islandService.find(name, {
56+
sortBy: sortBy,
57+
sortOrder: sortOrder,
58+
});
59+
}
60+
61+
@ApiOperation({ description: 'Get an island by its code.' })
62+
@ApiParam({
63+
name: 'code',
64+
description: 'The island code',
65+
required: true,
66+
type: 'string',
67+
example: '110140001',
68+
})
69+
@ApiOkResponse({ description: 'Returns an island.' })
70+
@ApiBadRequestResponse({ description: 'If the `code` is invalid.' })
71+
@ApiNotFoundResponse({
72+
description: 'If no island matches the `code`.',
73+
})
74+
@Get(':code')
75+
async findByCode(@Param() params: IslandFindByCodeParams): Promise<Island> {
76+
const { code } = params;
77+
const island = await this.islandService.findByCode(code);
78+
79+
if (!island) {
80+
throw new NotFoundException(`Island with code ${code} not found.`);
81+
}
82+
83+
return island;
84+
}
85+
}

src/island/island.dto.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {
2+
IsBooleanString,
3+
IsNotEmpty,
4+
IsNumberString,
5+
IsOptional,
6+
IsString,
7+
Length,
8+
} from 'class-validator';
9+
import { IsNotSymbol } from '../common/decorator/IsNotSymbol';
10+
import { EqualsAny } from '../common/decorator/EqualsAny';
11+
import { SortQuery } from '../common/helper/sort';
12+
import { IntersectionType, PickType } from '@nestjs/mapped-types';
13+
14+
export class Island {
15+
@IsNotEmpty()
16+
@IsNumberString()
17+
@Length(9, 9)
18+
code: string;
19+
20+
@IsNotEmpty()
21+
@IsString()
22+
coordinate: string;
23+
24+
@IsNotEmpty()
25+
@IsBooleanString()
26+
isOutermostSmall: boolean;
27+
28+
@IsNotEmpty()
29+
@IsBooleanString()
30+
isPopulated: boolean;
31+
32+
@IsNotEmpty()
33+
@IsNotSymbol("'-/")
34+
@Length(3, 255)
35+
name: string;
36+
37+
@IsOptional()
38+
@IsNumberString()
39+
@Length(4, 4)
40+
regencyCode?: string;
41+
}
42+
43+
export class IslandSortQuery extends SortQuery<'code' | 'name' | 'coordinate'> {
44+
@EqualsAny(['code', 'name', 'coordinate'])
45+
sortBy: 'code' | 'name';
46+
}
47+
48+
export class IslandFindQueries extends IntersectionType(
49+
PickType(Island, ['name'] as const),
50+
IslandSortQuery,
51+
) {}
52+
53+
export class IslandFindByCodeParams extends PickType(Island, [
54+
'code',
55+
] as const) {}

src/island/island.module.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Module } from '@nestjs/common';
2+
import { IslandController } from './island.controller';
3+
import { IslandService } from './island.service';
4+
import { PrismaService } from '../common/services/prisma';
5+
6+
@Module({
7+
providers: [IslandService, PrismaService],
8+
controllers: [IslandController],
9+
exports: [IslandService],
10+
})
11+
export class IslandModule {}

0 commit comments

Comments
 (0)