mirror of
https://github.com/Steffo99/distributed-arcade.git
synced 2024-11-21 15:44:26 +00:00
Some more final improvements
This commit is contained in:
parent
1a34bb1699
commit
67c94fdf50
11 changed files with 252 additions and 103 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
@ -143,7 +143,7 @@ checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff"
|
|||
|
||||
[[package]]
|
||||
name = "distributed_arcade"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
|
@ -157,6 +157,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "distributed_arcade"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
@ -18,3 +18,4 @@ pretty_env_logger = { version = "0.4.0" }
|
|||
rand = { version = "0.8.5" }
|
||||
regex = { version = "1.7.0" }
|
||||
async-trait = { version = "0.1.58" }
|
||||
tower-http = { version = "0.3.4", features=["cors"] }
|
||||
|
|
7
README.md
Normal file
7
README.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
# Distributed Arcade
|
||||
|
||||
Fast and simple scoreboard service for games
|
||||
|
||||
\[ [**Swagger UI**]() \]
|
||||
|
||||
Developed for [`Steffo99/unimore-bda-2`](https://github.com/Steffo99/unimore-bda-2)
|
51
docs/examples.http
Normal file
51
docs/examples.http
Normal file
|
@ -0,0 +1,51 @@
|
|||
### Verify that the webserver is working
|
||||
GET http://localhost:30000/
|
||||
|
||||
### Verify that everything is working
|
||||
POST http://localhost:30000/
|
||||
|
||||
### Create a board
|
||||
POST http://localhost:30000/board/
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "example",
|
||||
"order": "Descending"
|
||||
}
|
||||
|
||||
### Set a score on the board
|
||||
PUT http://localhost:30000/score/?board=example&player=steffo
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer adz313TlarO98B0P
|
||||
|
||||
1234.56
|
||||
|
||||
### Set another score on the board
|
||||
PUT http://localhost:30000/score/?board=example&player=offets
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer adz313TlarO98B0P
|
||||
|
||||
2412.25
|
||||
|
||||
### Improve the first score
|
||||
PUT http://localhost:30000/score/?board=example&player=steffo
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer adz313TlarO98B0P
|
||||
|
||||
6666.66
|
||||
|
||||
### Set a third score
|
||||
PUT http://localhost:30000/score/?board=example&player=oooooo
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer adz313TlarO98B0P
|
||||
|
||||
3333.33
|
||||
|
||||
### Get the leaderboards
|
||||
GET http://localhost:30000/board/?board=example&offset=0&size=10
|
||||
|
||||
### Get a player's rank
|
||||
GET http://localhost:30000/score/?board=example&player=steffo
|
||||
|
||||
### Get another player's rank
|
||||
GET http://localhost:30000/score/?board=example&player=offets
|
3
docs/index.html
Normal file
3
docs/index.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
</html>
|
|
@ -2,16 +2,32 @@ openapi: 3.0.3
|
|||
|
||||
info:
|
||||
title: "Distributed Arcade"
|
||||
version: 0.2.0
|
||||
description: |-
|
||||
A super-fast high score gatherer using Rust and Redis.
|
||||
version: 0.1.0
|
||||
|
||||
It allows the creation of _boards_, [sorted sets](https://redis.io/docs/data-types/sorted-sets/) that can be used to track the scores of players in videogames.
|
||||
|
||||
It is written to be extremely fast and scalable: it should be able to receive bursts of many requests.
|
||||
|
||||
Errors can be distinguished from successful requests via HTTP status codes >=400.
|
||||
contact:
|
||||
name: "Stefano Pigozzi"
|
||||
url: "https://www.steffo.eu"
|
||||
email: "me@steffo.eu"
|
||||
license:
|
||||
name: "GNU Affero General Public License v3.0 or later"
|
||||
url: https://github.com/Steffo99/distributed-arcade/blob/main/LICENSE.txt"
|
||||
|
||||
servers:
|
||||
- url: "https://arcade.steffo.eu"
|
||||
description: "Experimental production server"
|
||||
- url: "http://127.0.0.1:30000"
|
||||
description: "Local development server"
|
||||
|
||||
tags:
|
||||
- name: "Home"
|
||||
description: "Miscellaneous routes"
|
||||
description: "Launch checklist"
|
||||
- name: "Board"
|
||||
description: "About boards"
|
||||
- name: "Score"
|
||||
|
@ -20,27 +36,23 @@ tags:
|
|||
paths:
|
||||
/:
|
||||
get:
|
||||
operationId: "getHome"
|
||||
summary: "Verify that the web server is working as expected"
|
||||
description: |-
|
||||
This method simply echoes back a response, and can be used to verify that the API web server is working.
|
||||
tags: ["Home"]
|
||||
responses:
|
||||
200:
|
||||
204:
|
||||
description: "Working as expected"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
nullable: true
|
||||
example: null
|
||||
patch:
|
||||
post:
|
||||
operationId: "postHome"
|
||||
summary: "Verify that everything is working as expected"
|
||||
description: |-
|
||||
This method is like `GET /`, but it also `PING`s the Redis server to ensure it is working and configured properly.
|
||||
tags: ["Home"]
|
||||
responses:
|
||||
200:
|
||||
204:
|
||||
description: "Working as expected"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
nullable: true
|
||||
example: null
|
||||
500:
|
||||
description: "Did not receive `PONG` from redis"
|
||||
content:
|
||||
|
@ -55,7 +67,13 @@ paths:
|
|||
|
||||
/board/:
|
||||
get:
|
||||
operationId: "getBoard"
|
||||
summary: "Get the scores of a board"
|
||||
description: |-
|
||||
This method requests a page of scores from a board using the [`ZSCAN`](https://redis.io/commands/zscan/) Redis command.
|
||||
|
||||
An offset must be specified to start returning scores from a certain index.
|
||||
The number of responses to return must be specified as well.
|
||||
tags: ["Board"]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/board"
|
||||
|
@ -67,33 +85,37 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
offset:
|
||||
type: integer
|
||||
description: "The offset to pass to get the next page, or 0 if there are no more results."
|
||||
example: 1
|
||||
scores:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
description: "A score submitted by an user."
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: "The name of the user who submitted the score."
|
||||
example: "Steffo"
|
||||
score:
|
||||
type: number
|
||||
description: "The submitted score."
|
||||
example: 1234.56
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
description: "A score submitted by an user."
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: "The name of the user who submitted the score."
|
||||
example: "Steffo"
|
||||
score:
|
||||
type: number
|
||||
description: "The submitted score."
|
||||
example: 1234.56
|
||||
502:
|
||||
$ref: "#/components/responses/RedisCmdFailed"
|
||||
504:
|
||||
$ref: "#/components/responses/RedisConnFailed"
|
||||
|
||||
post:
|
||||
operationId: "postBoard"
|
||||
summary: "Create a new board"
|
||||
description: |-
|
||||
This method creates a new board.
|
||||
|
||||
It returns its _score submission token_ (`XBoardToken` in this spec), which is required to submit new scores to the board.
|
||||
|
||||
Boards can use two different orders:
|
||||
|
||||
- using the `Ascending` order, lower scores are better ranked than higher scores, like in racing games or golf;
|
||||
- using the `Descending` order, higher scores are better ranked than lower scores, like in arcade games or athletics.
|
||||
|
||||
**WARNING: Once created, a board cannot be edited or deleted, and its token will not be accessible any longer!**
|
||||
tags: ["Board"]
|
||||
requestBody:
|
||||
required: true
|
||||
|
@ -104,15 +126,15 @@ paths:
|
|||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: "The name of the board to create."
|
||||
description: "The name of the board to create. It will be converted to kebab-case."
|
||||
example: "gravityfusion"
|
||||
order:
|
||||
type: string
|
||||
example: "DSC"
|
||||
example: "Descending"
|
||||
description: "The ordering of the board, either ascending or descending."
|
||||
enum:
|
||||
- "ASC"
|
||||
- "DSC"
|
||||
- "Ascending"
|
||||
- "Descending"
|
||||
responses:
|
||||
201:
|
||||
description: "Board created successfully"
|
||||
|
@ -121,6 +143,15 @@ paths:
|
|||
schema:
|
||||
type: string
|
||||
example: "W4SbhbJ3tnGaIM1S"
|
||||
links:
|
||||
getScore:
|
||||
operationId: "getScore"
|
||||
parameters:
|
||||
board: "$response.body/name"
|
||||
putScore:
|
||||
operationId: "putScore"
|
||||
parameters:
|
||||
board: "$response.body/name"
|
||||
409:
|
||||
description: "Board already exists"
|
||||
content:
|
||||
|
@ -142,7 +173,10 @@ paths:
|
|||
|
||||
/score/:
|
||||
get:
|
||||
operationId: "getScore"
|
||||
summary: "Get a score from a board"
|
||||
description: |-
|
||||
Retrieve the score and the position that the given user has on the leaderboard.
|
||||
tags: ["Score"]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/board"
|
||||
|
@ -153,15 +187,23 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: number
|
||||
description: "The score of the specified player."
|
||||
example: 1234.56
|
||||
type: object
|
||||
properties:
|
||||
score:
|
||||
type: number
|
||||
description: "The score of the specified player."
|
||||
example: 1234.56
|
||||
rank:
|
||||
type: integer
|
||||
description: "The zero-indexed rank of the specified player. (You may probably want to add `1` before displaying it to an user.)"
|
||||
example: 0
|
||||
502:
|
||||
$ref: "#/components/responses/RedisCmdFailed"
|
||||
504:
|
||||
$ref: "#/components/responses/RedisConnFailed"
|
||||
|
||||
put:
|
||||
operationId: "putScore"
|
||||
summary: "Submit a score to a board"
|
||||
tags: ["Score"]
|
||||
parameters:
|
||||
|
@ -183,17 +225,31 @@ paths:
|
|||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: number
|
||||
description: "The previous score."
|
||||
example: 2468.12
|
||||
type: object
|
||||
properties:
|
||||
score:
|
||||
type: number
|
||||
description: "The score of the specified player."
|
||||
example: 1234.56
|
||||
rank:
|
||||
type: integer
|
||||
description: "The zero-indexed rank of the specified player. (You may probably want to add `1` before displaying it to an user.)"
|
||||
example: 0
|
||||
201:
|
||||
description: "Score submitted and updated"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: number
|
||||
description: "The new score."
|
||||
example: 1234.56
|
||||
type: object
|
||||
properties:
|
||||
score:
|
||||
type: number
|
||||
description: "The score of the specified player."
|
||||
example: 2468.13
|
||||
rank:
|
||||
type: integer
|
||||
description: "The zero-indexed rank of the specified player. (You may probably want to add `1` before displaying it to an user.)"
|
||||
example: 0
|
||||
401:
|
||||
description: "Missing, invalid or malformed Authorization header"
|
||||
content:
|
||||
|
@ -210,9 +266,9 @@ paths:
|
|||
components:
|
||||
securitySchemes:
|
||||
XBoardToken:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: "Authorization"
|
||||
type: http
|
||||
scheme: "Bearer"
|
||||
bearerFormat: "gVsuzIxgVfRx4RNl"
|
||||
|
||||
parameters:
|
||||
board:
|
|
@ -5,7 +5,7 @@ mod routes;
|
|||
mod shortcuts;
|
||||
|
||||
|
||||
use axum::routing::{get, post, put, patch};
|
||||
use axum::routing::{get, post, put};
|
||||
|
||||
|
||||
#[tokio::main]
|
||||
|
@ -22,12 +22,15 @@ async fn main() {
|
|||
|
||||
let webapp = axum::Router::new()
|
||||
.route("/", get(routes::home::route_home_get))
|
||||
.route("/", patch(routes::home::route_home_patch))
|
||||
.route("/", post(routes::home::route_home_post))
|
||||
.route("/board/", get(routes::board::route_board_get))
|
||||
.route("/board/", post(routes::board::route_board_post))
|
||||
.route("/score/", get(routes::score::route_score_get))
|
||||
.route("/score/", put(routes::score::route_score_put))
|
||||
.layer(axum::Extension(rclient));
|
||||
.layer(axum::Extension(rclient))
|
||||
.layer(tower_http::cors::CorsLayer::new()
|
||||
.allow_origin("*")
|
||||
);
|
||||
|
||||
log::info!("Starting Axum server...");
|
||||
|
||||
|
|
|
@ -42,11 +42,3 @@ pub(crate) fn redis_unexpected_behaviour() -> RequestTuple {
|
|||
req_error!("Redis gave an unexpected response")
|
||||
)
|
||||
}
|
||||
|
||||
/// The request succeeded, and there's no data to be returned.
|
||||
pub(crate) fn success_null() -> RequestTuple {
|
||||
(
|
||||
StatusCode::OK,
|
||||
req_success!(null)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -35,16 +35,6 @@ pub(crate) struct RouteBoardQuery {
|
|||
}
|
||||
|
||||
|
||||
/// Expected response for [`GET /board/`](route_board_get).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct RouteBoardResponse {
|
||||
/// The offset of the next page.
|
||||
pub(crate) offset: usize,
|
||||
/// The scores of the current page.
|
||||
pub(crate) scores: Vec<ScoreObject>,
|
||||
}
|
||||
|
||||
|
||||
/// A score set by a player, as a serializable struct.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct ScoreObject {
|
||||
|
@ -56,19 +46,14 @@ pub(crate) struct ScoreObject {
|
|||
|
||||
impl From<(String, f64)> for ScoreObject {
|
||||
fn from(t: (String, f64)) -> Self {
|
||||
ScoreObject {name: t.0, score: t.1}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(usize, Vec<(String, f64)>)> for RouteBoardResponse {
|
||||
fn from(t: (usize, Vec<(String, f64)>)) -> Self {
|
||||
RouteBoardResponse {
|
||||
offset: t.0,
|
||||
scores: t.1.into_iter().map(From::from).collect()
|
||||
ScoreObject {
|
||||
name: t.0,
|
||||
score: t.1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Ensure that there is nothing stored at a certain Redis key.
|
||||
async fn ensure_key_is_empty(rconn: &mut redis::aio::Connection, key: &str) -> Result<(), outcome::RequestTuple> {
|
||||
log::trace!("Ensuring that the Redis key `{key}` does not contain anything...");
|
||||
|
@ -100,15 +85,33 @@ pub(crate) async fn route_board_get(
|
|||
}
|
||||
|
||||
log::trace!("Determining the Redis key name...");
|
||||
let order_key = format!("board:{board}:order");
|
||||
let scores_key = format!("board:{board}:scores");
|
||||
|
||||
let mut rconn = rclient.get_connection_or_504().await?;
|
||||
|
||||
log::trace!("Determining sorting order...");
|
||||
let order = rconn.get::<&str, String>(&order_key).await
|
||||
.map_err(outcome::redis_cmd_failed)?;
|
||||
let order = SortingOrder::try_from(order.as_str())
|
||||
.map_err(|_| outcome::redis_unexpected_behaviour())?;
|
||||
log::trace!("Sorting order is: {order:?}");
|
||||
|
||||
log::trace!("Building score retrieval command...");
|
||||
let mut cmd = redis::Cmd::new();
|
||||
let mut cmd_with_args = cmd.arg("ZRANGE").arg(&scores_key).arg(&offset).arg(&offset + &size);
|
||||
if let SortingOrder::Descending = &order {
|
||||
cmd_with_args = cmd_with_args.arg("REV");
|
||||
}
|
||||
cmd_with_args = cmd_with_args.arg("WITHSCORES");
|
||||
|
||||
log::trace!("Retrieving scores from {board}...");
|
||||
let result: RouteBoardResponse = redis::cmd("ZSCAN").arg(&scores_key).arg(offset).arg("COUNT").arg(&size)
|
||||
.query_async::<redis::aio::Connection, (usize, Vec<(String, f64)>)>(&mut rconn).await
|
||||
let result: Vec<ScoreObject> = cmd_with_args
|
||||
.query_async::<redis::aio::Connection, Vec<(String, f64)>>(&mut rconn).await
|
||||
.map_err(outcome::redis_cmd_failed)?
|
||||
.into();
|
||||
.into_iter()
|
||||
.map(From::<(String, f64)>::from)
|
||||
.collect();
|
||||
|
||||
Ok((StatusCode::OK, outcome::req_success!(result)))
|
||||
}
|
||||
|
|
|
@ -1,25 +1,22 @@
|
|||
//! Module defining routes for `/`.
|
||||
|
||||
use axum::Extension;
|
||||
use axum::http::StatusCode;
|
||||
use crate::outcome;
|
||||
use crate::shortcuts::redis::RedisConnectOr504;
|
||||
|
||||
|
||||
/// Handler for `GET /`.
|
||||
///
|
||||
/// Verifies that the web server is working correctly.
|
||||
pub(crate) async fn route_home_get() -> outcome::RequestResult {
|
||||
pub(crate) async fn route_home_get() -> StatusCode {
|
||||
log::trace!("Echoing back a success...");
|
||||
Ok(outcome::success_null())
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
|
||||
/// Handler for `PATCH /`.
|
||||
///
|
||||
/// Pings Redis to verify that everything is working correctly.
|
||||
pub(crate) async fn route_home_patch(
|
||||
/// Handler for `POST /`.
|
||||
pub(crate) async fn route_home_post(
|
||||
Extension(rclient): Extension<redis::Client>
|
||||
) -> outcome::RequestResult {
|
||||
) -> Result<StatusCode, outcome::RequestTuple> {
|
||||
|
||||
let mut rconn = rclient.get_connection_or_504().await?;
|
||||
|
||||
|
@ -28,6 +25,6 @@ pub(crate) async fn route_home_patch(
|
|||
.query_async::<redis::aio::Connection, String>(&mut rconn).await
|
||||
.map_err(outcome::redis_cmd_failed)?
|
||||
.eq("PONG")
|
||||
.then(outcome::success_null)
|
||||
.then(|| StatusCode::NO_CONTENT)
|
||||
.ok_or_else(outcome::redis_unexpected_behaviour)
|
||||
}
|
||||
|
|
|
@ -23,6 +23,15 @@ pub(crate) struct RouteScoreQuery {
|
|||
}
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct RouteScoreResponse {
|
||||
/// The score the user has on the board.
|
||||
pub score: f64,
|
||||
/// The position of the user relative to the other users on the board, zero-based.
|
||||
pub rank: usize,
|
||||
}
|
||||
|
||||
|
||||
/// Handler for `GET /score/`.
|
||||
pub(crate) async fn route_score_get(
|
||||
// Request query
|
||||
|
@ -33,7 +42,8 @@ pub(crate) async fn route_score_get(
|
|||
let board = board.to_kebab_lowercase();
|
||||
let player = player.to_kebab_lowercase();
|
||||
|
||||
log::trace!("Determining the Redis key name...");
|
||||
log::trace!("Determining the Redis key names...");
|
||||
let order_key = format!("board:{board}:order");
|
||||
let scores_key = format!("board:{board}:scores");
|
||||
|
||||
let mut rconn = rclient.get_connection_or_504().await?;
|
||||
|
@ -43,9 +53,25 @@ pub(crate) async fn route_score_get(
|
|||
.map_err(outcome::redis_cmd_failed)?;
|
||||
log::trace!("Score is: {score:?}");
|
||||
|
||||
log::trace!("Determining sorting order...");
|
||||
let order = rconn.get::<&str, String>(&order_key).await
|
||||
.map_err(outcome::redis_cmd_failed)?;
|
||||
let order = SortingOrder::try_from(order.as_str())
|
||||
.map_err(|_| outcome::redis_unexpected_behaviour())?;
|
||||
log::trace!("Sorting order is: {order:?}");
|
||||
|
||||
log::trace!("Getting rank...");
|
||||
let rank = match order {
|
||||
SortingOrder::Ascending => rconn.zrank::<&str, &str, usize>(&scores_key, &player),
|
||||
SortingOrder::Descending => rconn.zrevrank::<&str, &str, usize>(&scores_key, &player),
|
||||
}.await.map_err(outcome::redis_cmd_failed)?;
|
||||
log::trace!("Rank is: {rank:?}");
|
||||
|
||||
let result = RouteScoreResponse {score, rank};
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
outcome::req_success!(score)
|
||||
outcome::req_success!(result)
|
||||
))
|
||||
}
|
||||
|
||||
|
@ -69,7 +95,7 @@ pub(crate) async fn route_score_put(
|
|||
let token_key = format!("board:{board}:token");
|
||||
let scores_key = format!("board:{board}:scores");
|
||||
|
||||
let token = headers.get_authorization_or_401("X-Board-Token")?;
|
||||
let token = headers.get_authorization_or_401("Bearer")?;
|
||||
let mut rconn = rclient.get_connection_or_504().await?;
|
||||
|
||||
log::trace!("Checking if the token exists and matches...");
|
||||
|
@ -103,11 +129,20 @@ pub(crate) async fn route_score_put(
|
|||
.map_err(outcome::redis_cmd_failed)?;
|
||||
log::trace!("Received score: {nscore:?}");
|
||||
|
||||
log::trace!("Getting rank...");
|
||||
let rank = match order {
|
||||
SortingOrder::Ascending => rconn.zrank::<&str, &str, usize>(&scores_key, &player),
|
||||
SortingOrder::Descending => rconn.zrevrank::<&str, &str, usize>(&scores_key, &player),
|
||||
}.await.map_err(outcome::redis_cmd_failed)?;
|
||||
log::trace!("Rank is: {rank:?}");
|
||||
|
||||
let result = RouteScoreResponse {score, rank};
|
||||
|
||||
Ok((
|
||||
match changed.gt(&0) {
|
||||
true => StatusCode::CREATED,
|
||||
false => StatusCode::OK,
|
||||
},
|
||||
outcome::req_success!(nscore)
|
||||
outcome::req_success!(result)
|
||||
))
|
||||
}
|
Loading…
Reference in a new issue