Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,33 @@ subscription { streamHello(name: "GraphQL") { message meta { correlationId } } }
query { user(id: "demo") { id displayName trusted } }
```

Upload mutation (uses the GraphQL `Upload` scalar; send as multipart):
```graphql
mutation ($file: Upload!) {
uploadAvatar(input: { userId: "demo", avatar: $file }) { userId size }
}
```
```
curl http://127.0.0.1:8888/graphql \
--form 'operations={ "query": "mutation ($file: Upload!) { uploadAvatar(input:{ userId:\"demo\", avatar:$file }) { userId size } }", "variables": { "file": null } }' \
--form 'map={ "0": ["variables.file"] }' \
--form '0=@./proto/greeter.proto;type=application/octet-stream'
```

Multi-upload mutation (list of `Upload`):
```graphql
mutation ($files: [Upload!]!) {
uploadAvatars(input: { userId: "demo", avatars: $files }) { userId sizes }
}
```
```
curl http://127.0.0.1:8888/graphql \
--form 'operations={ "query": "mutation ($files: [Upload!]!) { uploadAvatars(input:{ userId:\"demo\", avatars:$files }) { userId sizes } }", "variables": { "files": [null, null] } }' \
--form 'map={ "0": ["variables.files.0"], "1": ["variables.files.1"] }' \
--form '0=@./proto/greeter.proto;type=application/octet-stream' \
--form '1=@./README.md;type=text/plain'
```

## How it fits together
A quick view of how protobuf descriptors, the generated schema, and gRPC clients are wired to serve GraphQL over HTTP and WebSocket:
```mermaid
Expand Down Expand Up @@ -176,11 +203,13 @@ let builder = Gateway::builder()
- `int32`/`uint32` -> `Int`
- `int64`/`uint64` -> `String` (to avoid precision loss)
- `float`/`double` -> `Float`
- `bytes` -> `String` (base64)
- `bytes` -> `Upload` (inputs via multipart) / `String` (base64 responses)
- `repeated` -> `[T]`
- `message` -> `Object` / `InputObject`
- `enum` -> `Enum`

`Upload` inputs follow the GraphQL multipart request spec and are valid on mutations.

## Development
- Format: `cargo fmt`
- Lint/tests: `cargo test`
Expand Down
78 changes: 77 additions & 1 deletion examples/greeter/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use std::time::Duration;
use anyhow::Result;
use futures::StreamExt;
use grpc_graphql_gateway::{Gateway, GrpcClient};
use tokio::fs;
use tokio::sync::RwLock;
use tokio_stream::wrappers::IntervalStream;
use tokio_stream::Stream;
Expand All @@ -18,7 +19,10 @@ pub mod greeter {
}

use greeter::greeter_server::{Greeter, GreeterServer};
use greeter::{GetUserRequest, GreetMeta, HelloReply, HelloRequest, UpdateGreetingRequest, User};
use greeter::{
GetUserRequest, GreetMeta, HelloReply, HelloRequest, UpdateGreetingRequest, UploadAvatarReply,
UploadAvatarRequest, UploadAvatarsReply, UploadAvatarsRequest, User,
};

const DESCRIPTORS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/greeter_descriptor.bin"));
const GRPC_ADDR: &str = "127.0.0.1:50051";
Expand Down Expand Up @@ -105,6 +109,8 @@ fn print_examples(addr: SocketAddr) {
" subscription {{ streamHello(name:\"GraphQL\") {{ message meta {{ correlationId }} }} }}"
);
println!(" query {{ user(id:\"demo\") {{ id displayName trusted }} }}");
println!(" # Upload (multipart): see README for the curl example");
println!(" # Multi-upload (multipart): see README for the curl example");
}

#[derive(Clone)]
Expand Down Expand Up @@ -211,6 +217,69 @@ impl Greeter for ExampleGreeter {

Err(Status::not_found(format!("user {} not found", req.id)))
}

async fn upload_avatar(
&self,
request: Request<UploadAvatarRequest>,
) -> Result<Response<UploadAvatarReply>, Status> {
let req = request.into_inner();
if self.lookup_user(&req.user_id).await.is_none() {
return Err(Status::not_found(format!("user {} not found", req.user_id)));
}

let mut path = std::env::temp_dir();
path.push(format!(
"greeter_avatar_{}.bin",
safe_filename(&req.user_id)
));
fs::write(&path, &req.avatar)
.await
.map_err(|e| Status::internal(format!("failed to write avatar: {e}")))?;
info!("stored avatar for {} at {}", req.user_id, path.display());

let size = req.avatar.len() as u64;
let reply = UploadAvatarReply {
user_id: req.user_id,
size,
};
Ok(Response::new(reply))
}

async fn upload_avatars(
&self,
request: Request<UploadAvatarsRequest>,
) -> Result<Response<UploadAvatarsReply>, Status> {
let req = request.into_inner();
if self.lookup_user(&req.user_id).await.is_none() {
return Err(Status::not_found(format!("user {} not found", req.user_id)));
}

let mut sizes = Vec::with_capacity(req.avatars.len());
for (idx, blob) in req.avatars.iter().enumerate() {
let mut path = std::env::temp_dir();
path.push(format!(
"greeter_avatar_{}_{}.bin",
safe_filename(&req.user_id),
idx
));
fs::write(&path, blob)
.await
.map_err(|e| Status::internal(format!("failed to write avatar: {e}")))?;
info!(
"stored avatar {} for {} at {}",
idx,
req.user_id,
path.display()
);
sizes.push(blob.len() as u64);
}

let reply = UploadAvatarsReply {
user_id: req.user_id,
sizes,
};
Ok(Response::new(reply))
}
}

impl ExampleGreeter {
Expand Down Expand Up @@ -239,3 +308,10 @@ fn normalize_name(name: String) -> String {
name
}
}

fn safe_filename(input: &str) -> String {
input
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect()
}
38 changes: 38 additions & 0 deletions proto/greeter.proto
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ message GetUserRequest {
string id = 1 [(graphql.field) = {required: true}];
}

message UploadAvatarRequest {
string user_id = 1 [(graphql.field) = {required: true, name: "userId"}];
bytes avatar = 2 [(graphql.field) = {required: true}];
}

message UploadAvatarReply {
string user_id = 1 [(graphql.field) = {name: "userId"}];
uint64 size = 2;
}
Comment on lines +41 to +44

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Sizes returned as numbers despite String schema

The new upload mutations return uint64 sizes (UploadAvatarReply.size/UploadAvatarsReply.sizes), but the schema builder still exposes all 64‑bit integers as GraphQL String to avoid precision loss. At runtime prost_value_to_graphql converts Value::U64 to a numeric GqlValue, so calling uploadAvatar/uploadAvatars will advertise a String field in introspection yet emit JSON numbers, violating the declared type and risking client parsing errors. Please align the serialization with the declared string type before shipping the upload responses.

Useful? React with 👍 / 👎.


message UploadAvatarsRequest {
string user_id = 1 [(graphql.field) = {required: true, name: "userId"}];
repeated bytes avatars = 2 [(graphql.field) = {required: true}];
}

message UploadAvatarsReply {
string user_id = 1 [(graphql.field) = {name: "userId"}];
repeated uint64 sizes = 2;
}

service Greeter {
option (graphql.service) = {
host: "http://127.0.0.1:50051"
Expand Down Expand Up @@ -70,4 +90,22 @@ service Greeter {
response { required: true }
};
}

rpc UploadAvatar(UploadAvatarRequest) returns (UploadAvatarReply) {
option (graphql.schema) = {
type: MUTATION
name: "uploadAvatar"
request { name: "input" }
response { required: true }
};
}

rpc UploadAvatars(UploadAvatarsRequest) returns (UploadAvatarsReply) {
option (graphql.schema) = {
type: MUTATION
name: "uploadAvatars"
request { name: "input" }
response { required: true }
};
}
}
Loading
Loading