ifc_lite_server/routes/parse/
json.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! JSON / SSE-JSON parse endpoints.
6
7use super::cache_keys::{json_response_cache_key, request_cache_key};
8use super::{cache_symbolic_data_off_runtime, extract_file, ParseQuery};
9use crate::error::ApiError;
10use crate::services::axis::mesh_to_yup_in_place;
11use crate::services::streaming::detect_schema_version;
12use crate::services::process_streaming;
13use crate::types::{finish_meshes, MetadataResponse, ParseResponse, SymbolicParseResponse, StreamEvent};
14use ifc_lite_processing::style::ModelFinishes;
15use crate::AppState;
16use axum::{
17    body::Body,
18    extract::{Multipart, Query, State},
19    http::header,
20    response::sse::{Event, KeepAlive, Sse},
21    response::{IntoResponse, Response},
22    Json,
23};
24use bytes::Bytes;
25use futures::stream::StreamExt;
26use ifc_lite_core::EntityScanner;
27use ifc_lite_processing::process_geometry_filtered_with_quality;
28use std::convert::Infallible;
29
30/// POST /api/v1/parse - Full synchronous parse.
31pub async fn parse_full(
32    State(state): State<AppState>,
33    Query(query): Query<ParseQuery>,
34    mut multipart: Multipart,
35) -> Result<Response, ApiError> {
36    // Extract file from multipart
37    // Admission gate (bounded concurrency + byte budget): acquired BEFORE the
38    // upload is buffered, reserving the max upload size since multipart rarely
39    // declares a length up front. Held for the request's whole lifetime so a
40    // disconnected-but-still-running job keeps its memory slot.
41    let admission_guard = state
42        .admission
43        .acquire(state.config.max_file_size_mb as u64 * 1024 * 1024)
44        .await?;
45    let data = extract_file(&mut multipart, state.config.max_file_size_mb).await?;
46
47    // Generate cache key (include opening filter so different modes get different cache entries)
48    let tessellation_quality = query.resolved_tessellation_quality()?;
49    let cache_key = request_cache_key(&data, &query, tessellation_quality);
50    // The stored ParseResponse is versioned separately from the client-facing
51    // cache_key so the Y-up switch (#1841) retires the old Z-up entries without
52    // invalidating the parquet caches. See `json_response_cache_key`.
53    let response_cache_key = json_response_cache_key(&cache_key);
54
55    // Check cache first. The entry is the whole model as JSON, so a hit is
56    // decoded and re-encoded on the blocking pool (#4696). The admission guard
57    // rides that task, as it does the miss path's below.
58    if let Some(cached) = state.cache.get_bytes(&response_cache_key).await? {
59        tracing::info!(cache_key = %cache_key, "Cache HIT");
60        let (body, _admission) = tokio::task::spawn_blocking(move || {
61            let decoded = serde_json::from_slice::<SymbolicParseResponse>(&cached);
62            drop(cached);
63            let mut cached = decoded?;
64            cached.mark_from_cache();
65            Ok::<_, ApiError>((encode_response(&cached)?, admission_guard))
66        })
67        .await??;
68        return Ok(json_body(body));
69    }
70
71    tracing::info!(cache_key = %cache_key, size = data.len(), "Cache MISS - processing");
72
73    // Parse content
74    let content = data;
75    let opening_filter = query.opening_filter;
76    let response_key = cache_key.clone();
77
78    // Process on blocking thread pool (CPU-intensive). Bundle the 3D
79    // geometry with the 2D symbolic-data extraction (issue #843) so
80    // callers can render IfcGrid axes and IfcAnnotation polylines from
81    // the same response without re-uploading the file. The Y-up swap and the
82    // whole-model encode run in the same task, not on the async worker that
83    // resumes this handler (#4696), and one encoded body serves both the
84    // client and the cache.
85    // The guard moves INTO the blocking task and comes back with the result:
86    // if the TimeoutLayer (or a disconnect) cancels this handler future, the
87    // detached blocking work keeps running - and keeps its admission slot -
88    // until it actually exits, so a replacement cannot be admitted on top.
89    let (body, symbolic_data, _admission) = tokio::task::spawn_blocking(move || {
90        let result =
91            process_geometry_filtered_with_quality(&content, opening_filter, tessellation_quality);
92        // In the frame the meshes above were baked in, not one this re-derives
93        // for itself: the two travel in one response, so a site-local model's
94        // symbols have to lose the same site translation and rotation its
95        // meshes did (#4706).
96        let symbolic = ifc_lite_processing::extract_symbolic_data_with_provenance_in_frame(
97            &content,
98            result.frame,
99        );
100        // #5984: each mesh's IFC-authored finish, joined before the bytes go.
101        let mut meshes = finish_meshes(&mut ModelFinishes::from_content(&content), result.meshes);
102        drop(content);
103
104        // Emit the SAME Y-up wire frame as the parquet transports (issue #1841).
105        // This route used to ship `result.meshes` verbatim — i.e. raw IFC Z-up —
106        // while `/parse/parquet*` swapped to Y-up server-side, so a client that
107        // (correctly) treats every transport alike rendered JSON-loaded models
108        // rotated. The frame now comes from one place: services::axis.
109        for mesh in &mut meshes {
110            mesh_to_yup_in_place(mesh);
111        }
112
113        let response = SymbolicParseResponse::new(ParseResponse {
114            cache_key: response_key,
115            meshes: Vec::new(),
116            mesh_coordinate_space: Some(result.mesh_coordinate_space),
117            site_transform: result.site_transform,
118            building_transform: result.building_transform,
119            metadata: result.metadata,
120            stats: result.stats,
121            symbolic_data: Default::default(),
122        }, symbolic)
123        .with_meshes(meshes);
124        let body = encode_response(&response)?;
125        Ok::<_, ApiError>((body, response.symbolic_data, admission_guard))
126    })
127    .await??;
128
129    // Cache result (background). Also mirror the symbolic stream into the
130    // dedicated `{cache_key}-symbolic-v4` entry so it's reachable through
131    // `GET /api/v1/parse/symbolic/{cache_key}` regardless of which endpoint
132    // first processed the file (issue #900).
133    let cache = state.cache.clone();
134    let cached_body = body.clone();
135    tokio::spawn(async move {
136        cache_symbolic_data_off_runtime(cache.clone(), cache_key, symbolic_data).await;
137        if let Err(e) = cache.set_bytes(&response_cache_key, &cached_body).await {
138            tracing::error!(error = %e, "Failed to cache result");
139        }
140    });
141
142    Ok(json_body(body))
143}
144
145/// Encode a `POST /api/v1/parse` body. It is the whole model, so call this
146/// only on the blocking pool (#4696). The bytes are the ones `Json` would
147/// write and `DiskCache::set` would store.
148fn encode_response(response: &SymbolicParseResponse) -> Result<Bytes, ApiError> {
149    let body = serde_json::to_vec(response)?;
150    tracing::info!(cache_key = %response.cache_key, size = body.len(), "JSON parse response encoded");
151    Ok(body.into())
152}
153
154/// An already-encoded JSON body, with the content type `Json` sets.
155fn json_body(body: Bytes) -> Response {
156    ([(header::CONTENT_TYPE, "application/json")], Body::from(body)).into_response()
157}
158
159/// POST /api/v1/parse/stream - Streaming SSE parse.
160pub async fn parse_stream(
161    State(state): State<AppState>,
162    Query(query): Query<ParseQuery>,
163    mut multipart: Multipart,
164) -> Result<Response, ApiError> {
165    let tessellation_quality = query.resolved_tessellation_quality()?;
166
167    // Extract file
168    // Admission gate (bounded concurrency + byte budget): acquired BEFORE the
169    // upload is buffered, reserving the max upload size since multipart rarely
170    // declares a length up front. Held for the request's whole lifetime so a
171    // disconnected-but-still-running job keeps its memory slot.
172    let admission_guard = state
173        .admission
174        .acquire(state.config.max_file_size_mb as u64 * 1024 * 1024)
175        .await?;
176    let data = extract_file(&mut multipart, state.config.max_file_size_mb).await?;
177
178    let content = data;
179    let initial_batch_size = state.config.initial_batch_size;
180    let max_batch_size = state.config.max_batch_size;
181
182    // Create streaming response with dynamic batch sizing
183    let stream = process_streaming(
184        content,
185        initial_batch_size,
186        max_batch_size,
187        query.opening_filter,
188        tessellation_quality,
189        Some(admission_guard),
190    )
191    .map(|mut event: StreamEvent| {
192            // Same Y-up wire frame as every other transport (issue #1841) —
193            // `process_streaming` yields raw IFC Z-up meshes, and the parquet
194            // stream route applies the swap inside its serializer, so the JSON
195            // stream has to apply it here rather than in the shared producer.
196            if let StreamEvent::Batch { meshes, .. } = &mut event {
197                for mesh in meshes.iter_mut() {
198                    mesh_to_yup_in_place(mesh);
199                }
200            }
201            let json = serde_json::to_string(&event).unwrap_or_else(|e| {
202                serde_json::to_string(&StreamEvent::Error {
203                    message: e.to_string(),
204                })
205                .unwrap()
206            });
207            Ok::<_, Infallible>(Event::default().data(json))
208        });
209
210    Ok(Sse::new(stream)
211        .keep_alive(KeepAlive::default())
212        .into_response())
213}
214
215/// POST /api/v1/parse/metadata - Quick metadata only (no geometry).
216pub async fn parse_metadata(
217    State(state): State<AppState>,
218    mut multipart: Multipart,
219) -> Result<Json<MetadataResponse>, ApiError> {
220    // Extract file
221    // Admission gate (bounded concurrency + byte budget): acquired BEFORE the
222    // upload is buffered, reserving the max upload size since multipart rarely
223    // declares a length up front. Held for the request's whole lifetime so a
224    // disconnected-but-still-running job keeps its memory slot.
225    let admission_guard = state
226        .admission
227        .acquire(state.config.max_file_size_mb as u64 * 1024 * 1024)
228        .await?;
229    let data = extract_file(&mut multipart, state.config.max_file_size_mb).await?;
230
231    let file_size = data.len();
232    let content = data;
233
234    // Fast path - just scan entities, no geometry processing
235    let result = tokio::task::spawn_blocking(move || {
236        let mut scanner = EntityScanner::new(&content);
237        let mut entity_count = 0usize;
238        let mut geometry_count = 0usize;
239
240        while let Some((_, type_name, _, _)) = scanner.next_entity() {
241            entity_count += 1;
242            if ifc_lite_core::has_geometry_by_name(type_name) {
243                geometry_count += 1;
244            }
245        }
246
247        // The walk above ends on `None`, which does not only mean "the file
248        // ended": the scanner skips a record whose instance name does not fit
249        // `u32` (#3395) and stops the whole scan at a record with no
250        // terminator (#3695). Either way `entity_count` is computed over less
251        // than the file declares, and a 200 carrying it reads exactly like a
252        // complete answer. Report to the server's sink like every other
253        // whole-file walk, AND carry both onto the response — a client cannot
254        // read the server log (#3791).
255        let oversized_id_count = scanner.skipped_oversized_ids();
256        let malformed_record_found = scanner.malformed_record_start().is_some();
257        ifc_lite_core::report_scan_diagnostics(oversized_id_count, malformed_record_found);
258
259        let schema_version = detect_schema_version(&content);
260
261        (
262            MetadataResponse {
263                entity_count,
264                geometry_count,
265                schema_version: schema_version.to_string(),
266                file_size,
267                oversized_id_count,
268                malformed_record_found,
269            },
270            admission_guard,
271        )
272    })
273    .await?;
274    let (result, _admission) = result;
275
276    Ok(Json(result))
277}