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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
/target
Justfile
Justfile
/commit.sh
/GO.sh
35 changes: 31 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,34 @@

Hulykvs is a simple key-value store service implemented in Rust. It uses cockroachdb as the backend and provides a simple http api for storing and retrieving key-value pairs.

## API
## API v2
Create a key-value pair api

```POST /api2/{workspace}/{namespace}/{key}```
Stores request payload as the value for the given key in the given namespace. Existing keys will be overwritten. Returs 204 (NoContent) on sucesss.


```GET /api2/{workspace}/{namespace}/{key}```
Retrieves the value for the given key in the given namespace. Returns 404 if the key does not exist.


```DELETE /api2/{workspace}/{namespace}/{key}```
Deletes the key-value pair for the given key in the given namespace. Returns 404 if the key does not exist, 204 (NoContent) on success, 404 if the key does not exist.


```GET /api2/{workspace}/{namespace}?[prefix=<prefix>]```
Retrieves all key-value pairs in the given namespace. Optionally, a prefix can be provided to filter the results. The following structure is returned:
```json
{
"workspace": "workspace",
"namespace": "namespace",
"count": 3,
"keys": ["key1", "key2", "keyN"]
}
```
## API (old)
workspace = "defaultspace"

Create a key-value pair

```POST /api/{namespace}/{key}```
Expand All @@ -20,14 +47,14 @@ Deletes the key-value pair for the given key in the given namespace. Returns 404
Retrieves all key-value pairs in the given namespace. Optionally, a prefix can be provided to filter the results. The following structure is returned:
```json
{
"namespace": "namespace",
"namespace": "namespace",
"count": 3,
"keys": ["key1", "key2", "keyN"]
}
```

## Running
Pre-build docker images is available at: hardcoreeng/service_hulykvs:{tag}.
Pre-build docker images is available at: hardcoreeng/service_hulykvs:{tag}.

You can use the following command to run the image locally:
```bash
Expand Down
12 changes: 12 additions & 0 deletions commmit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/bin/bash

clear

#git checkout -b feature/workspace-support
git add .
git commit -m "Add API v2 with workspace"

exit


git push origin feature/workspace-support
1 change: 1 addition & 0 deletions hulykvs/hulykvs
19 changes: 19 additions & 0 deletions hulykvs_server/etc/migrations/V2__add_workspace.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- 1. Create new
CREATE TABLE kvs_new (
workspace TEXT NOT NULL,
namespace TEXT NOT NULL,
key TEXT NOT NULL,
md5 BYTES NOT NULL,
value BYTES NOT NULL,
PRIMARY KEY (workspace, namespace, key)
);

-- 2. Copy
INSERT INTO kvs_new (workspace, namespace, key, md5, value)
SELECT 'defaultspace', namespace, key, md5, value FROM kvs;

-- 3. Del
DROP TABLE kvs;

-- 4. Rename
ALTER TABLE kvs_new RENAME TO kvs;
24 changes: 12 additions & 12 deletions hulykvs_server/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ pub async fn get(
let connection = pool.get().await?;

let statement = r#"
select value from kvs where namespace=$1 and key=$2
select value from kvs where workspace=$1 and namespace=$2 and key=$3
"#;

let result = connection.query(statement, &[&nsstr, &keystr]).await?;
let result = connection.query(statement, &[&"defaultspace", &nsstr, &keystr]).await?;

let response = match result.as_slice() {
[] => HttpResponse::NotFound().finish(),
Expand Down Expand Up @@ -76,16 +76,16 @@ pub async fn post(
let md5 = md5::compute(&body);

let statement = r#"
insert into kvs(namespace, key, md5, value)
values($1, $2, $3, $4)
on conflict(namespace, key)
insert into kvs(workspace, namespace, key, md5, value)
values($1, $2, $3, $4, $5)
on conflict(workspace, workspace, namespace, key)
do update set
md5=excluded.md5,
value=excluded.value
"#;

connection
.execute(statement, &[&nsstr, &keystr, &&md5[..], &&body[..]])
.execute(statement, &[&"defaultspace", &nsstr, &keystr, &&md5[..], &&body[..]])
.await?;

Ok(HttpResponse::NoContent().finish())
Expand All @@ -111,10 +111,10 @@ pub async fn delete(
let connection = pool.get().await?;

let statement = r#"
delete from kvs where namespace=$1 and key=$2
delete from kvs where workspace=$1 and namespace=$2 and key=$3
"#;

let response = match connection.execute(statement, &[&nsstr, &keystr]).await? {
let response = match connection.execute(statement, &[&"defaultspace", &nsstr, &keystr]).await? {
1 => HttpResponse::NoContent(),
0 => HttpResponse::NotFound(),
_ => panic!("multiple rows deleted, unique constraint is probably violated"),
Expand Down Expand Up @@ -157,16 +157,16 @@ pub async fn list(
let response = if let Some(prefix) = &query.prefix {
let pattern = format!("{}%", prefix);
let statement = r#"
select key from kvs where namespace=$1 and key like $2
select key from kvs where workspace=$1 and namespace=$2 and key like $3
"#;

connection.query(statement, &[&nsstr, &pattern]).await?
connection.query(statement, &[&"defaultspace",&nsstr, &pattern]).await?
} else {
let statement = r#"
select key from kvs where namespace=$1
select key from kvs where workspace=$1 and namespace=$2
"#;

connection.query(statement, &[&nsstr]).await?
connection.query(statement, &[&"defaultspace",&nsstr]).await?
};

let count = response.len();
Expand Down
205 changes: 205 additions & 0 deletions hulykvs_server/src/handlers_v2.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

use actix_web::{
HttpResponse, error,
web::{self, Data, Json, Query},
};
use serde::{Deserialize, Serialize};
use tracing::{error, trace};

use super::Pool;

type BucketPath = web::Path<(String, String)>;
type ObjectPath = web::Path<(String, String, String)>;

pub async fn get(
path: ObjectPath,
pool: Data<Pool>,
) -> Result<HttpResponse, actix_web::error::Error> {
let (workspace, namespace, key) = path.into_inner();
trace!(workspace, namespace, key, "get request");

let wsstr = workspace.as_str();
let nsstr = namespace.as_str();
let keystr = key.as_str();

async move || -> anyhow::Result<HttpResponse> {
let connection = pool.get().await?;

let statement = r#"
select value from kvs where workspace=$1 and namespace=$2 and key=$3
"#;

let result = connection.query(statement, &[&wsstr, &nsstr, &keystr]).await?;

let response = match result.as_slice() {
[] => HttpResponse::NotFound().finish(),
[found] => HttpResponse::Ok().body(found.get::<_, Vec<u8>>("value")),
_ => panic!("multiple rows found, unique constraint is probably violated"),
};

Ok(response)
}()
.await
.map_err(|error| {
error!(op = "get", workspace, namespace, key, ?error, "internal error");
error::ErrorInternalServerError("")
})
}



pub async fn post(
path: ObjectPath,
pool: Data<Pool>,
body: web::Bytes,
) -> Result<HttpResponse, actix_web::error::Error> {
let (workspace, namespace, key) = path.into_inner();
trace!(workspace, namespace, key, "post request");

let wsstr = workspace.as_str();
let nsstr = namespace.as_str();
let keystr = key.as_str();

async move || -> anyhow::Result<HttpResponse> {
let connection = pool.get().await?;

let md5 = md5::compute(&body);

let statement = r#"
INSERT INTO kvs(workspace, namespace, key, md5, value)
VALUES($1, $2, $3, $4, $5)
ON CONFLICT (workspace, namespace, key)
DO UPDATE SET
md5 = excluded.md5,
value = excluded.value
"#;

connection
.execute(statement, &[&wsstr, &nsstr, &keystr, &&md5[..], &&body[..]])
.await?;

Ok(HttpResponse::NoContent().finish())
}()
.await
.map_err(|error| {
error!(op = "upsert", workspace, namespace, key, ?error, "internal error");
error::ErrorInternalServerError("")
})
}



pub async fn delete(
path: ObjectPath,
pool: Data<Pool>,
) -> Result<HttpResponse, actix_web::error::Error> {
let (workspace, namespace, key) = path.into_inner();
trace!(workspace, namespace, key, "delete request");

let wsstr = workspace.as_str();
let nsstr = namespace.as_str();
let keystr = key.as_str();

async move || -> anyhow::Result<HttpResponse> {
let connection = pool.get().await?;

let statement = r#"
DELETE FROM kvs WHERE workspace=$1 AND namespace=$2 AND key=$3
"#;

let response = match connection.execute(statement, &[&wsstr, &nsstr, &keystr]).await? {
1 => HttpResponse::NoContent(),
0 => HttpResponse::NotFound(),
_ => panic!("multiple rows deleted, unique constraint is probably violated"),
};

Ok(response.into())
}()
.await
.map_err(|error| {
error!(op = "delete", workspace, namespace, key, ?error, "internal error");
error::ErrorInternalServerError("")
})
}


#[derive(Deserialize)]
pub struct ListInfo {
prefix: Option<String>,
}

#[derive(Serialize)]
pub struct ListResponse {
workspace: String,
namespace: String,
count: usize,
keys: Vec<String>,
}

pub async fn list(
path: BucketPath,
pool: Data<Pool>,
query: Query<ListInfo>,
) -> Result<Json<ListResponse>, actix_web::error::Error> {
let (workspace, namespace) = path.into_inner();
trace!(workspace, namespace, prefix = ?query.prefix, "list request");

let wsstr = workspace.as_str();
let nsstr = namespace.as_str();

async move || -> anyhow::Result<Json<ListResponse>> {
let connection = pool.get().await?;

let response = if let Some(prefix) = &query.prefix {
let pattern = format!("{}%", prefix);
let statement = r#"
select key from kvs where workspace=$1 and namespace=$2 and key like $3
"#;

connection.query(statement, &[&wsstr, &nsstr, &pattern]).await?
} else {
let statement = r#"
select key from kvs where workspace=$1 and namespace=$2
"#;

connection.query(statement, &[&wsstr, &nsstr]).await?
};

let count = response.len();

let keys = response.into_iter().map(|row| row.get(0)).collect();

/*
let mut keys = Vec::new();
for row in response {
keys.push(row.get::<_, String>(0));
}
*/

Ok(Json(ListResponse {
keys,
count,
namespace: nsstr.to_owned(),
workspace: wsstr.to_owned(),
}))
}()
.await
.map_err(|error| {
error!(op = "list", workspace, namespace, ?error, "internal error");
error::ErrorInternalServerError("")
})
}
9 changes: 9 additions & 0 deletions hulykvs_server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use tracing::info;

mod config;
mod handlers;
mod handlers_v2;
mod token;

use config::CONFIG;
Expand Down Expand Up @@ -147,6 +148,14 @@ async fn main() -> anyhow::Result<()> {
.route("/{bucket}/{id}", web::post().to(handlers::post))
.route("/{bucket}/{id}", web::delete().to(handlers::delete)),
)
.service(
web::scope("/api2")
.wrap(middleware::from_fn(interceptor))
.route("/{workspace}/{bucket}", web::get().to(handlers_v2::list))
.route("/{workspace}/{bucket}/{id}", web::get().to(handlers_v2::get))
.route("/{workspace}/{bucket}/{id}", web::post().to(handlers_v2::post))
.route("/{workspace}/{bucket}/{id}", web::delete().to(handlers_v2::delete)),
)
.route("/status", web::get().to(async || "ok"))
})
.bind(socket)?
Expand Down
Loading