Skip to content

Commit 0e2857a

Browse files
committed
Crying in GraphQL
1 parent 53e83c6 commit 0e2857a

8 files changed

Lines changed: 414 additions & 516 deletions

File tree

Cargo.lock

Lines changed: 0 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/resource-info-fetcher/Cargo.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,9 @@ reqwest.workspace = true
2323
serde.workspace = true
2424
serde_json.workspace = true
2525
snafu.workspace = true
26-
strum.workspace = true
2726
tokio.workspace = true
2827
tracing.workspace = true
2928
url.workspace = true
30-
urlencoding.workspace = true
3129

3230
[build-dependencies]
3331
built.workspace = true

rust/resource-info-fetcher/src/api.rs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
use std::sync::Arc;
2-
31
use hyper::StatusCode;
42
use info_fetcher_commons::http_error;
53
use serde::{Deserialize, Serialize};
@@ -66,14 +64,6 @@ pub enum GetResourceInfoError {
6664
display("failed to get resource information from DataHub")
6765
)]
6866
DataHub { source: backend::data_hub::Error },
69-
70-
#[snafu(
71-
visibility(pub(crate)),
72-
display("failed to get user information from DataHub")
73-
)]
74-
GetDataHubUser {
75-
source: Arc<backend::data_hub::Error>,
76-
},
7767
}
7868

7969
impl http_error::Error for GetResourceInfoError {
@@ -87,7 +77,6 @@ impl http_error::Error for GetResourceInfoError {
8777
match self {
8878
Self::SerializeResponseAsJson { .. } => StatusCode::INTERNAL_SERVER_ERROR,
8979
Self::DataHub { source } => source.status_code(),
90-
Self::GetDataHubUser { source } => source.status_code(),
9180
}
9281
}
9382
}
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
//! DataHub GraphQL query and the types used to (de)serialize it.
2+
//!
3+
//! A single `POST /api/graphql` fetches the entity together with its tags and owners, and DataHub
4+
//! resolves the referenced tag/user/group and ownership-type entities server-side. The [`Entity`]
5+
//! response is then flattened into the crate's public [`DataHubResourceInfoResponse`].
6+
7+
use std::collections::BTreeMap;
8+
9+
use serde::{Deserialize, Serialize};
10+
11+
use crate::backend::data_hub::{DataHubResourceInfoResponse, Group, Owners, Tag, Urn, User};
12+
13+
/// A single query covering every entity kind we build URNs for. We use the generic `entity(urn:)`
14+
/// resolver plus per-type inline fragments, because a request can target a dataset (Trino table or
15+
/// Kafka topic), a container (Trino catalog or schema), a chart or a dashboard.
16+
const RESOURCE_INFO_QUERY: &str = r"
17+
query ResourceInfo($urn: String!) {
18+
entity(urn: $urn) {
19+
...ResourceInfo
20+
}
21+
}
22+
fragment ResourceInfo on Entity {
23+
... on Dataset { tags { ...Tags } ownership { ...Owners } }
24+
... on Container { tags { ...Tags } ownership { ...Owners } }
25+
... on Chart { tags { ...Tags } ownership { ...Owners } }
26+
... on Dashboard { tags { ...Tags } ownership { ...Owners } }
27+
}
28+
fragment Tags on GlobalTags {
29+
tags { tag { urn properties { name } } }
30+
}
31+
fragment Owners on Ownership {
32+
owners {
33+
owner {
34+
__typename
35+
... on CorpUser { urn properties { fullName displayName email active } }
36+
... on CorpGroup { urn properties { displayName description } }
37+
}
38+
ownershipType { urn info { name } }
39+
type
40+
}
41+
}
42+
";
43+
44+
/// Builds the request body for the [`RESOURCE_INFO_QUERY`], parameterized by the entity's URN.
45+
pub fn request(urn: &Urn) -> GraphQlRequest<'_> {
46+
GraphQlRequest {
47+
query: RESOURCE_INFO_QUERY,
48+
variables: Variables { urn: &urn.0 },
49+
}
50+
}
51+
52+
#[derive(Debug, Serialize)]
53+
pub struct GraphQlRequest<'a> {
54+
query: &'static str,
55+
variables: Variables<'a>,
56+
}
57+
58+
#[derive(Debug, Serialize)]
59+
struct Variables<'a> {
60+
urn: &'a str,
61+
}
62+
63+
/// A GraphQL server answers `200 OK` even when the query fails; the failures are reported in
64+
/// `errors`. Callers must therefore inspect `errors` explicitly rather than relying on the HTTP
65+
/// status code.
66+
#[derive(Debug, Deserialize)]
67+
pub struct GraphQlResponse {
68+
pub data: Option<ResponseData>,
69+
70+
#[serde(default)]
71+
pub errors: Vec<GraphQlError>,
72+
}
73+
74+
#[derive(Debug, Deserialize)]
75+
pub struct GraphQlError {
76+
pub message: String,
77+
}
78+
79+
#[derive(Debug, Deserialize)]
80+
pub struct ResponseData {
81+
pub entity: Option<Entity>,
82+
}
83+
84+
/// The resolved entity. `tags` and `ownership` come from the per-type inline fragments; GraphQL
85+
/// merges them onto the entity object, so a single flat struct reads them regardless of the
86+
/// concrete entity type. [`Default`] yields the "no metadata" entity used when DataHub does not
87+
/// return an entity for a URN.
88+
#[derive(Debug, Default, Deserialize)]
89+
pub struct Entity {
90+
tags: Option<GlobalTags>,
91+
ownership: Option<Ownership>,
92+
}
93+
94+
#[derive(Debug, Deserialize)]
95+
struct GlobalTags {
96+
tags: Vec<TagAssociation>,
97+
}
98+
99+
#[derive(Debug, Deserialize)]
100+
struct TagAssociation {
101+
tag: TagNode,
102+
}
103+
104+
#[derive(Debug, Deserialize)]
105+
struct TagNode {
106+
urn: Urn,
107+
properties: Option<TagProperties>,
108+
}
109+
110+
#[derive(Debug, Deserialize)]
111+
struct TagProperties {
112+
name: String,
113+
}
114+
115+
#[derive(Debug, Deserialize)]
116+
struct Ownership {
117+
owners: Vec<Owner>,
118+
}
119+
120+
#[derive(Debug, Deserialize)]
121+
#[serde(rename_all = "camelCase")]
122+
struct Owner {
123+
owner: OwnerEntity,
124+
125+
/// The modern ownership type entity, e.g. `urn:li:ownershipType:__system__technical_owner`.
126+
ownership_type: Option<OwnershipType>,
127+
128+
/// The legacy ownership type enum, e.g. `TECHNICAL_OWNER`. Used as a fallback key for owners
129+
/// that predate ownership type entities.
130+
#[serde(rename = "type")]
131+
legacy_type: Option<String>,
132+
}
133+
134+
#[derive(Debug, Deserialize)]
135+
struct OwnershipType {
136+
urn: Urn,
137+
info: Option<OwnershipTypeInfo>,
138+
}
139+
140+
#[derive(Debug, Deserialize)]
141+
struct OwnershipTypeInfo {
142+
name: String,
143+
}
144+
145+
/// The resolved owner. DataHub only ever resolves owners to users or groups.
146+
#[derive(Debug, Deserialize)]
147+
#[serde(tag = "__typename")]
148+
enum OwnerEntity {
149+
CorpUser {
150+
urn: Urn,
151+
properties: Option<CorpUserProperties>,
152+
},
153+
CorpGroup {
154+
urn: Urn,
155+
properties: Option<CorpGroupProperties>,
156+
},
157+
}
158+
159+
#[derive(Debug, Deserialize)]
160+
#[serde(rename_all = "camelCase")]
161+
struct CorpUserProperties {
162+
full_name: Option<String>,
163+
display_name: Option<String>,
164+
email: Option<String>,
165+
active: Option<bool>,
166+
}
167+
168+
#[derive(Debug, Deserialize)]
169+
#[serde(rename_all = "camelCase")]
170+
struct CorpGroupProperties {
171+
display_name: Option<String>,
172+
description: Option<String>,
173+
}
174+
175+
impl Entity {
176+
/// Flattens the GraphQL response into the crate's public [`DataHubResourceInfoResponse`].
177+
pub fn into_response(self, urn: Urn) -> DataHubResourceInfoResponse {
178+
let tags = self
179+
.tags
180+
.into_iter()
181+
.flat_map(|global_tags| global_tags.tags)
182+
.map(|association| {
183+
let TagNode { urn, properties } = association.tag;
184+
// A tag without a properties aspect has no name; fall back to its URN.
185+
let name = properties
186+
.map(|properties| properties.name)
187+
.unwrap_or_else(|| urn.0.clone());
188+
Tag { urn, name }
189+
})
190+
.collect();
191+
192+
let mut owners: BTreeMap<Urn, Owners> = BTreeMap::new();
193+
for owner in self
194+
.ownership
195+
.into_iter()
196+
.flat_map(|ownership| ownership.owners)
197+
{
198+
let (type_urn, type_name) = owner_type(owner.ownership_type, owner.legacy_type);
199+
let bucket = owners.entry(type_urn).or_default();
200+
if bucket.ownership_type_name.is_none() {
201+
bucket.ownership_type_name = type_name;
202+
}
203+
match owner.owner {
204+
OwnerEntity::CorpUser { urn, properties } => {
205+
bucket.users.push(user(urn, properties))
206+
}
207+
OwnerEntity::CorpGroup { urn, properties } => {
208+
bucket.groups.push(group(urn, properties))
209+
}
210+
}
211+
}
212+
213+
DataHubResourceInfoResponse { urn, tags, owners }
214+
}
215+
}
216+
217+
/// Resolves the map key and human-readable name for an owner's ownership type, preferring the
218+
/// modern ownership type entity and falling back to the legacy `type` enum. Unlike the previous
219+
/// REST implementation, this handles arbitrary (including user-defined) ownership types instead of
220+
/// assuming a fixed enum.
221+
fn owner_type(
222+
ownership_type: Option<OwnershipType>,
223+
legacy_type: Option<String>,
224+
) -> (Urn, Option<String>) {
225+
match ownership_type {
226+
Some(ownership_type) => (
227+
ownership_type.urn,
228+
ownership_type.info.map(|info| info.name),
229+
),
230+
// Fallback for owners where DataHub only populated the legacy `type` field.
231+
None => (
232+
Urn(legacy_type.unwrap_or_else(|| "unknown".to_owned())),
233+
None,
234+
),
235+
}
236+
}
237+
238+
fn user(urn: Urn, properties: Option<CorpUserProperties>) -> User {
239+
match properties {
240+
Some(properties) => User {
241+
full_name: properties.full_name,
242+
display_name: properties
243+
.display_name
244+
.unwrap_or_else(|| strip_user_urn(&urn)),
245+
email: properties.email,
246+
active: properties.active.unwrap_or(true),
247+
urn,
248+
},
249+
// An owner reference without a corpUserInfo aspect: derive a display name from the URN.
250+
None => User {
251+
full_name: None,
252+
display_name: strip_user_urn(&urn),
253+
email: None,
254+
active: true,
255+
urn,
256+
},
257+
}
258+
}
259+
260+
fn group(urn: Urn, properties: Option<CorpGroupProperties>) -> Group {
261+
let (display_name, description) = match properties {
262+
Some(properties) => (
263+
properties.display_name.unwrap_or_else(|| urn.0.clone()),
264+
properties.description,
265+
),
266+
None => (urn.0.clone(), None),
267+
};
268+
269+
Group {
270+
urn,
271+
display_name,
272+
description,
273+
}
274+
}
275+
276+
fn strip_user_urn(urn: &Urn) -> String {
277+
urn.0.trim_start_matches("urn:li:corpuser:").to_owned()
278+
}

0 commit comments

Comments
 (0)