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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.3.1]

- Add support for custom CA certificates and allow skipping TLS verification entirely
- Breaking: GraphQL `interface` and `union` output types are now lowered into tagged-variant NDC object types by default.
- Lowered object types include `__typename` and one nullable field per concrete type (`on_<ConcreteType>`).
- Query generation translates tagged variant selections to GraphQL inline fragments (`... on <ConcreteType>`).
- Validation now fails early with clear errors for unsupported polymorphic schemas (for example unions/interfaces without concrete object members, or unresolved type references).

## [0.3.0]

- Upgrade ndc-spec v0.2.10 and ndc-rust-sdk
Expand Down
17 changes: 14 additions & 3 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
members = ["crates/ndc-graphql", "crates/ndc-graphql-cli", "crates/common"]
resolver = "2"

package.version = "0.3.0"
package.version = "0.3.1"
package.edition = "2021"
package.license = "Apache-2.0"

Expand Down
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ Below, you'll find a matrix of all supported features for the GraphQL connector:
| Header Passthrough | ✅ | Entire headers can be forwarded |
| Request-level Arguments | ✅ | Support dynamic headers from the from Pre-NDC Request Plugin |
| Subscriptions | ❌ | |
| Unions | | Can be brought in via scalar types |
| Interfaces | | |
| Unions | | Lowered to tagged-variant object types (`__typename`, `on_<Type>`) |
| Interfaces | | Lowered to tagged-variant object types (`__typename`, `on_<Type>`) |
| Relay API | ❌ | |
| Directives | ❌ | @cached, Apollo directives |

Expand All @@ -57,6 +57,10 @@ Below, you'll find a matrix of all supported features for the GraphQL connector:
* Error formatting
- The format of errors from the connector does not currently match V2 error formatting
- No "partial error" or "multiple errors" responses
* Polymorphic output lowering
- GraphQL `interface` and `union` outputs are lowered into synthetic object types in NDC metadata.
- Lowered object types expose `__typename` plus one nullable tagged field per concrete type: `on_<ConcreteType>`.
- When lowering is not possible (for example no concrete object members), config/schema validation fails with a targeted error.
* Pattern matching in request header forwarding configuration
- This uses simple glob patterns
- More advanced matching and extraction is not currently supported
Expand All @@ -71,11 +75,11 @@ Please see the [relevant documentation](https://hasura.info/graphql-getting-star
## Advanced Features

### Forward Headers from Pre-NDC Request Plugin

You can use a [Pre-NDC Request Plugin](https://hasura.io/docs/3.0/plugins/introduction#pre-ndc-request-plugin) to modify the request, and add dynamic headers in runtime via `request_arguments.headers` field, which is a string map. Those headers will be merged into the HTTP request headers before being sent to external services.

> See the full example at [Pre-NDC Request Plugin Request](https://hasura.io/docs/3.0/plugins/introduction#example-configuration)

```json
{
// ...
Expand All @@ -89,4 +93,19 @@ You can use a [Pre-NDC Request Plugin](https://hasura.io/docs/3.0/plugins/introd
}
}
}
```
```

### TLS/SSL Configuration

The connector uses `rustls` for TLS which does not automatically pick up system certificate stores. For self-hosted deployments using organization-signed certificates, you can configure custom CA certificates or disable TLS verification via environment variables.

| Variable | Description |
|----------|-------------|
| `GRAPHQL_CA_CERT_FILE` | Path to a single PEM-encoded CA certificate file |
| `GRAPHQL_CA_CERT_DIR` | Directory containing PEM-encoded CA certificates (`.pem`, `.crt`, `.cer`) |
| `GRAPHQL_INSECURE_SKIP_TLS_VERIFY` | Set to `true` or `1` to disable TLS verification |

**Notes:**
- `GRAPHQL_CA_CERT_FILE` and `GRAPHQL_CA_CERT_DIR` can be used together (certificates are combined)
- `GRAPHQL_INSECURE_SKIP_TLS_VERIFY` takes precedence and skips all CA cert logic when enabled
- **Warning:** Disabling TLS verification is insecure and should only be used for development/testing
2 changes: 2 additions & 0 deletions crates/common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ graphql_client = { workspace = true }
graphql-parser = { workspace = true }
ndc-models = { workspace = true }
reqwest = { workspace = true }
rustls-pemfile = "2"
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
92 changes: 87 additions & 5 deletions crates/common/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,101 @@ use crate::config::ConnectionConfig;
use glob_match::glob_match;
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
use serde::Serialize;
use std::{collections::BTreeMap, error::Error, fmt::Debug};
use std::{
collections::BTreeMap,
env,
error::Error,
fmt::Debug,
fs::{self, File},
io::BufReader,
path::Path,
};

const CA_CERT_FILE_ENV: &str = "GRAPHQL_CA_CERT_FILE";
const CA_CERT_DIR_ENV: &str = "GRAPHQL_CA_CERT_DIR";
const INSECURE_SKIP_VERIFY_ENV: &str = "GRAPHQL_INSECURE_SKIP_TLS_VERIFY";

fn load_certs_from_file(path: &Path) -> Result<Vec<reqwest::Certificate>, Box<dyn Error>> {
if !path.is_file() {
return Err(format!("{} is not a file", path.display()).into());
}
tracing::info!("Loading CA certs from file: {}", path.display());
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let certs = rustls_pemfile::certs(&mut reader).collect::<Result<Vec<_>, _>>()?;
let certs: Vec<_> = certs
.into_iter()
.map(|cert| reqwest::Certificate::from_der(cert.as_ref()).map_err(Into::into))
.collect::<Result<_, Box<dyn Error>>>()?;
tracing::info!("Loaded {} cert(s) from {}", certs.len(), path.display());
Ok(certs)
}

fn load_certs_from_dir(dir_path: &str) -> Result<Vec<reqwest::Certificate>, Box<dyn Error>> {
let path = Path::new(dir_path);
if !path.is_dir() {
return Err(format!("{} is not a directory", dir_path).into());
}
tracing::info!("Loading CA certs from directory: {}", dir_path);

let mut all_certs = Vec::new();
for entry in fs::read_dir(path)?.flatten() {
let file_path = entry.path();
let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !matches!(ext.to_lowercase().as_str(), "pem" | "crt" | "cer") || !file_path.is_file() {
continue;
}
match load_certs_from_file(&file_path) {
Ok(certs) => all_certs.extend(certs),
Err(e) => tracing::warn!("Failed to load {}: {}", file_path.display(), e),
}
}

if all_certs.is_empty() {
return Err(format!("No valid certificates found in {}", dir_path).into());
}
tracing::info!(
"Loaded {} total CA cert(s) from {}",
all_certs.len(),
dir_path
);
Ok(all_certs)
}

pub fn get_http_client(
_connection_config: &ConnectionConfig,
) -> Result<reqwest::Client, Box<dyn std::error::Error>> {
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let mut builder = reqwest::Client::builder().default_headers(headers);

// Insecure mode: skip all TLS verification, ignore CA cert settings
let insecure = env::var(INSECURE_SKIP_VERIFY_ENV)
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false);
if insecure {
tracing::warn!(
"TLS verification DISABLED via {} - insecure!",
INSECURE_SKIP_VERIFY_ENV
);
return Ok(builder.danger_accept_invalid_certs(true).build()?);
}

// Load certs from file (if set)
if let Ok(cert_file) = env::var(CA_CERT_FILE_ENV) {
for cert in load_certs_from_file(Path::new(&cert_file))? {
builder = builder.add_root_certificate(cert);
}
}

let client = reqwest::Client::builder()
.default_headers(headers)
.build()?;
// Load certs from directory (if set) - can be used together with file
if let Ok(cert_dir) = env::var(CA_CERT_DIR_ENV) {
for cert in load_certs_from_dir(&cert_dir)? {
builder = builder.add_root_certificate(cert);
}
}

Ok(client)
Ok(builder.build()?)
}

pub async fn execute_graphql<T: serde::de::DeserializeOwned>(
Expand Down
Loading
Loading