Skip to content

Commit 4624d39

Browse files
committed
CHANGES:
- RestAPI: schema validation and documentation added
1 parent 9397cca commit 4624d39

File tree

3 files changed

+514
-27
lines changed

3 files changed

+514
-27
lines changed

plugins/restapi/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,12 @@ The following commands are available through the API
4141
| /squadron_members | POST | name: string | [{"name": string, "date": date}] | Lists the members of the squadron with that name. |
4242
| /linkme | POST | discord_id: string, force: bool | {"token": 1234, "timestamp": "2025-02-03 xx:xx:xx...", "rc": 0} | Same as /linkme in discord. Returns a new token that can be used in the in-game chat. |
4343
44+
> [!NOTE]
45+
> To access the API documentation, you can enable debug and access the documentation with these links:
46+
> http://localhost:9876/docs
47+
> http://localhost:9876/redoc
48+
> Please refer to the [OpenAPI specification](https://swagger.io/specification/) for more information and the
49+
> warning about debug above.
50+
4451
> [!IMPORTANT]
4552
> It is advisable to use a reverse proxy like nginx and maybe SSL encryption between your webserver and this endpoint.

plugins/restapi/commands.py

Lines changed: 129 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
from uvicorn import Config
2020

2121
from . import __version__
22+
from .models import (TopKill, ServerInfo, SquadronInfo, TopKDR, Trueskill, Highscore, UserEntry, MissilePK, PlayerStats,
23+
CampaignCredits, TrapEntry, SquadronMember, SquadronCampaignCredit, LinkMeResponse)
2224

2325
app: Optional[FastAPI] = None
2426

@@ -38,20 +40,118 @@ def __init__(self, bot: DCSServerBot):
3840
cfg = self.locals[DEFAULT_TAG]
3941
prefix = cfg.get('prefix', '')
4042
self.router = APIRouter()
41-
self.router.add_api_route(prefix + "/servers", self.servers, methods=["GET"])
42-
self.router.add_api_route(prefix + "/squadrons", self.squadrons, methods=["GET"])
43-
self.router.add_api_route(prefix + "/topkills", self.topkills, methods=["GET"])
44-
self.router.add_api_route(prefix + "/topkdr", self.topkdr, methods=["GET",])
45-
self.router.add_api_route(prefix + "/trueskill", self.trueskill, methods=["GET"])
46-
self.router.add_api_route(prefix + "/getuser", self.getuser, methods=["POST"])
47-
self.router.add_api_route(prefix + "/missilepk", self.missilepk, methods=["POST"])
48-
self.router.add_api_route(prefix + "/stats", self.stats, methods=["POST"])
49-
self.router.add_api_route(prefix + "/highscore", self.highscore, methods=["GET"])
50-
self.router.add_api_route(prefix + "/credits", self.credits, methods=["POST"])
51-
self.router.add_api_route(prefix + "/traps", self.traps, methods=["POST"])
52-
self.router.add_api_route(prefix + "/squadron_members", self.squadron_members, methods=["POST"])
53-
self.router.add_api_route(prefix + "/squadron_credits", self.squadron_credits, methods=["POST"])
54-
self.router.add_api_route(prefix + "/linkme", self.linkme, methods=["POST"])
43+
self.router.add_api_route(
44+
prefix + "/servers", self.servers,
45+
methods = ["GET"],
46+
response_model = list[ServerInfo],
47+
description = "List all servers, the active mission (if any) and the active extensions",
48+
summary = "Server list",
49+
tags = ["Info"]
50+
)
51+
self.router.add_api_route(
52+
prefix + "/squadrons", self.squadrons,
53+
methods = ["GET"],
54+
response_model = list[SquadronInfo],
55+
description = "List all squadrons and their roles",
56+
summary = "Squadron list",
57+
tags = ["Info"]
58+
)
59+
self.router.add_api_route(
60+
prefix + "/squadron_members", self.squadron_members,
61+
methods = ["POST"],
62+
response_model = list[SquadronMember],
63+
description = "List squadron members",
64+
summary = "Squadron Members",
65+
tags = ["Info"]
66+
)
67+
self.router.add_api_route(
68+
prefix + "/getuser", self.getuser,
69+
methods = ["POST"],
70+
response_model = list[UserEntry],
71+
description = "Get users by name",
72+
summary = "User list",
73+
tags = ["Info"]
74+
)
75+
self.router.add_api_route(
76+
prefix + "/linkme", self.linkme,
77+
methods=["POST"],
78+
response_model=LinkMeResponse,
79+
description="Link your Discord account to your DCS account",
80+
summary="Link Discord to DCS",
81+
tags=["Info"]
82+
)
83+
self.router.add_api_route(
84+
prefix + "/topkills", self.topkills,
85+
methods = ["GET"],
86+
response_model = list[TopKill],
87+
description = "Get AA top kills statistics for players",
88+
summary = "AA-Top Kills",
89+
tags = ["Statistics"]
90+
)
91+
self.router.add_api_route(
92+
prefix + "/topkdr", self.topkdr,
93+
methods = ["GET"],
94+
response_model = list[TopKDR],
95+
description = "Get AA top KDR statistics for players",
96+
summary = "AA-Top KDR",
97+
tags = ["Statistics"]
98+
)
99+
self.router.add_api_route(
100+
prefix + "/trueskill", self.trueskill,
101+
methods = ["GET"],
102+
response_model = list[Trueskill],
103+
description = "Get TrueSkill:tm: statistics for players",
104+
summary = "TrueSkill:tm:",
105+
tags = ["Statistics"]
106+
)
107+
self.router.add_api_route(
108+
prefix + "/missilepk", self.missilepk,
109+
methods = ["POST"],
110+
response_model = list[MissilePK],
111+
description = "Get missile PK statistics for players",
112+
summary = "Missile PK",
113+
tags = ["Statistics"]
114+
)
115+
self.router.add_api_route(
116+
prefix + "/stats", self.stats,
117+
methods = ["POST"],
118+
response_model = PlayerStats,
119+
description = "Get player statistics",
120+
summary = "Player Statistics",
121+
tags = ["Statistics"]
122+
)
123+
self.router.add_api_route(
124+
prefix + "/highscore", self.highscore,
125+
methods = ["GET"],
126+
response_model = Highscore,
127+
description = "Get highscore statistics for players",
128+
summary = "Highscore",
129+
tags = ["Statistics"]
130+
)
131+
self.router.add_api_route(
132+
prefix + "/traps", self.traps,
133+
methods = ["POST"],
134+
response_model = list[TrapEntry],
135+
description = "Get traps for players",
136+
summary = "Carrier Traps",
137+
tags = ["Statistics"]
138+
)
139+
self.router.add_api_route(
140+
prefix + "/credits", self.credits,
141+
methods = ["POST"],
142+
response_model = CampaignCredits,
143+
description = "Get campaign credits for players",
144+
summary = "Campaign Credits",
145+
tags = ["Credits"]
146+
)
147+
self.router.add_api_route(
148+
prefix + "/squadron_credits", self.squadron_credits,
149+
methods = ["POST"],
150+
response_model = list[SquadronCampaignCredit],
151+
description = "List squadron campaign credits",
152+
summary = "Squadron Credits",
153+
tags = ["Credits"]
154+
)
55155

56156
# add debug endpoints
57157
if cfg.get('debug', False):
@@ -149,7 +249,9 @@ async def servers(self):
149249
else:
150250
data['extensions'] = []
151251

152-
servers.append(data)
252+
# validate the data against the schema and return it
253+
servers.append(ServerInfo.model_validate(data))
254+
153255
return servers
154256

155257
async def squadrons(self):
@@ -180,7 +282,7 @@ async def topkills(self, limit: int = Query(default=10)):
180282
AND hop_on > (now() AT TIME ZONE 'utc') - interval '1 month'
181283
GROUP BY 1, 2 ORDER BY 3 DESC LIMIT {limit}
182284
""".format(limit=limit if limit else 10))
183-
return await cursor.fetchall()
285+
return [TopKill.model_validate(result) for result in await cursor.fetchall()]
184286

185287
async def topkdr(self, limit: int = Query(default=10)):
186288
async with self.apool.connection() as conn:
@@ -194,7 +296,7 @@ async def topkdr(self, limit: int = Query(default=10)):
194296
AND hop_on > (now() AT TIME ZONE 'utc') - interval '1 month'
195297
GROUP BY 1, 2 ORDER BY 5 DESC LIMIT {limit}
196298
""".format(limit=limit if limit else 10))
197-
return await cursor.fetchall()
299+
return [TopKDR.model_validate(result) for result in await cursor.fetchall()]
198300

199301
async def trueskill(self, limit: int = Query(default=10)):
200302
async with self.apool.connection() as conn:
@@ -208,7 +310,7 @@ async def trueskill(self, limit: int = Query(default=10)):
208310
AND hop_on > (now() AT TIME ZONE 'utc') - interval '1 month'
209311
GROUP BY 1, 2, 5 ORDER BY 5 DESC LIMIT {limit}
210312
""".format(limit=limit if limit else 10))
211-
return await cursor.fetchall()
313+
return [Trueskill.model_validate(result) for result in await cursor.fetchall()]
212314

213315
async def highscore(self, server_name: str = Query(default=None), period: str = Query(default='all'),
214316
limit: int = Query(default=10)):
@@ -218,7 +320,7 @@ async def highscore(self, server_name: str = Query(default=None), period: str =
218320
async with conn.cursor(row_factory=dict_row) as cursor:
219321
sql = """
220322
SELECT p.name AS nick, DATE_TRUNC('second', p.last_seen) AS "date",
221-
ROUND(SUM(EXTRACT(EPOCH FROM(COALESCE(s.hop_off, NOW() AT TIME ZONE 'UTC') - s.hop_on)))) AS playtime
323+
ROUND(SUM(EXTRACT(EPOCH FROM(COALESCE(s.hop_off, NOW() AT TIME ZONE 'UTC') - s.hop_on))))::INTEGER AS playtime
222324
FROM statistics s,
223325
players p,
224326
missions m
@@ -264,7 +366,7 @@ async def highscore(self, server_name: str = Query(default=None), period: str =
264366
await cursor.execute(sql, {"server_name": server_name})
265367
highscore[kill_type] = await cursor.fetchall()
266368

267-
return highscore
369+
return Highscore.model_validate(highscore, by_alias=True)
268370

269371
async def getuser(self, nick: str = Form(default=None)):
270372
async with self.apool.connection() as conn:
@@ -277,7 +379,7 @@ async def getuser(self, nick: str = Form(default=None)):
277379
WHERE name ILIKE %s
278380
ORDER BY 2 DESC
279381
""", ('%' + nick + '%',))
280-
return await cursor.fetchall()
382+
return [UserEntry.model_validate(result) for result in await cursor.fetchall()]
281383

282384
async def missilepk(self, nick: str = Form(default=None), date: str = Form(default=None)):
283385
async with self.apool.connection() as conn:
@@ -355,7 +457,7 @@ async def stats(self, nick: str = Form(default=None), date: str = Form(default=N
355457
ORDER BY 2 DESC
356458
""", (ucid,))
357459
data['kdrByModule'] = await cursor.fetchall()
358-
return data
460+
return PlayerStats.model_validate(data)
359461

360462
async def credits(self, nick: str = Form(default=None), date: str = Form(default=None)):
361463
self.log.debug(f'Calling /credits with nick="{nick}", date="{date}"')
@@ -380,7 +482,7 @@ async def credits(self, nick: str = Form(default=None), date: str = Form(default
380482
WHERE (now() AT TIME ZONE 'utc') BETWEEN c.start AND COALESCE(c.stop, now() AT TIME ZONE 'utc')
381483
GROUP BY 1, 2
382484
""", (ucid, ))
383-
return await cursor.fetchone()
485+
return CampaignCredits.model_validate(await cursor.fetchone())
384486

385487
async def traps(self, nick: str = Form(default=None), date: str = Form(default=None),
386488
limit: int = Form(default=10)):
@@ -406,7 +508,7 @@ async def traps(self, nick: str = Form(default=None), date: str = Form(default=N
406508
WHERE player_ucid = %s
407509
ORDER BY time DESC LIMIT {limit}
408510
""", (ucid, ))
409-
return await cursor.fetchall()
511+
return [TrapEntry.model_validate(result) for result in await cursor.fetchall()]
410512

411513
async def squadron_members(self, name: str = Form(default=None)):
412514
self.log.debug(f'Calling /squadron_members with name="{name}"')
@@ -495,16 +597,16 @@ async def create_token() -> str:
495597
else:
496598
token = await create_token()
497599
expiry_timestamp = (datetime.now() + timedelta(hours=48)).isoformat()
498-
# Set bit_field for new user
600+
# Set bit_field for a new user
499601
rc = 0 # Default bit_field for new user
500602
if force:
501603
rc |= BIT_FORCE_OPERATION # Set force operation flag
502604

503-
return {
605+
return LinkMeResponse.model_validate({
504606
"token": token,
505607
"timestamp": expiry_timestamp,
506608
"rc": rc
507-
}
609+
})
508610

509611

510612
async def setup(bot: DCSServerBot):

0 commit comments

Comments
 (0)