Skip to main content

opendal_layer_mime_guess/
lib.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18#![doc = include_str!("../README.md")]
19#![cfg_attr(docsrs, feature(doc_cfg))]
20#![cfg_attr(docsrs, doc(auto_cfg))]
21#![deny(missing_docs)]
22use std::sync::Arc;
23
24use opendal_core::raw::*;
25use opendal_core::*;
26
27/// `MimeGuessLayer` sets `Content-Type` from a path's file extension.
28///
29/// # MimeGuess
30///
31/// This layer uses [mime_guess](https://crates.io/crates/mime_guess) to automatically
32/// set `Content-Type` based on the file extension in the operation path.
33///
34/// The layer preserves any `content_type` that callers or services set.
35///
36/// For example, object storage services often return `content_type` from `stat`.
37/// In that case, the layer keeps the service's value and skips MIME guessing.
38///
39/// The [Fs](https://docs.rs/opendal/latest/opendal/services/struct.Fs.html)
40/// service might omit `content_type` from `stat`, so the layer derives a value
41/// from the path's extension.
42///
43/// The layer cannot infer every custom or uncommon extension. It leaves
44/// `content_type` empty when
45/// [mime_guess::from_path::first_raw](https://docs.rs/mime_guess/latest/mime_guess/struct.MimeGuess.html#method.first_raw)
46/// returns `None`.
47///
48/// # Examples
49///
50/// ```no_run
51/// # use opendal_core::services;
52/// # use opendal_core::Operator;
53/// # use opendal_core::Result;
54/// # use opendal_layer_mime_guess::MimeGuessLayer;
55/// #
56/// # fn main() -> Result<()> {
57/// let _ = Operator::new(services::Memory::default())?
58///     .layer(MimeGuessLayer::new());
59/// # Ok(())
60/// # }
61/// ```
62#[derive(Clone, Debug, Default)]
63#[non_exhaustive]
64pub struct MimeGuessLayer {}
65
66impl MimeGuessLayer {
67    /// Create a new [`MimeGuessLayer`].
68    pub fn new() -> Self {
69        Self::default()
70    }
71}
72
73impl Layer for MimeGuessLayer {
74    fn apply_service(&self, inner: Servicer) -> Servicer {
75        Arc::new(self.layer(inner))
76    }
77}
78
79impl MimeGuessLayer {
80    fn layer(&self, inner: Servicer) -> MimeGuessAccessor {
81        MimeGuessAccessor(inner)
82    }
83}
84
85#[doc(hidden)]
86#[derive(Debug)]
87pub struct MimeGuessAccessor(Servicer);
88
89fn mime_from_path(path: &str) -> Option<&str> {
90    mime_guess::from_path(path).first_raw()
91}
92
93fn opwrite_with_mime(path: &str, op: OpWrite) -> OpWrite {
94    if op.content_type().is_some() {
95        return op;
96    }
97
98    if let Some(mime) = mime_from_path(path) {
99        return op.into_content_type(mime);
100    }
101
102    op
103}
104
105fn opcompose_with_mime(path: &str, op: OpCompose) -> OpCompose {
106    if op.content_type().is_some() {
107        return op;
108    }
109
110    if let Some(mime) = mime_from_path(path) {
111        return op.into_content_type(mime);
112    }
113
114    op
115}
116
117fn rpstat_with_mime(path: &str, rp: RpStat) -> RpStat {
118    rp.map_metadata(|metadata| {
119        if metadata.content_type().is_some() {
120            return metadata;
121        }
122
123        if let Some(mime) = mime_from_path(path) {
124            let mut metadata = metadata.into_builder();
125            metadata.content_type(mime);
126            return metadata.build();
127        }
128
129        metadata
130    })
131}
132
133impl Service for MimeGuessAccessor {
134    type Reader = oio::Reader;
135    type Writer = oio::Writer;
136    type Lister = oio::Lister;
137    type Deleter = oio::Deleter;
138    type Copier = oio::Copier;
139    type Composer = oio::Composer;
140
141    fn info(&self) -> ServiceInfo {
142        self.0.info()
143    }
144
145    fn capability(&self) -> Capability {
146        self.0.capability()
147    }
148
149    async fn create_dir(
150        &self,
151        ctx: &OperationContext,
152        path: &str,
153        args: OpCreateDir,
154    ) -> Result<RpCreateDir> {
155        self.0.create_dir(ctx, path, args).await
156    }
157
158    fn read(&self, ctx: &OperationContext, path: &str, args: OpRead) -> Result<Self::Reader> {
159        self.0.read(ctx, path, args)
160    }
161
162    fn write(&self, ctx: &OperationContext, path: &str, args: OpWrite) -> Result<Self::Writer> {
163        self.0.write(ctx, path, opwrite_with_mime(path, args))
164    }
165
166    fn copy(
167        &self,
168        ctx: &OperationContext,
169        from: &str,
170        to: &str,
171        args: OpCopy,
172    ) -> Result<Self::Copier> {
173        self.0.copy(ctx, from, to, args)
174    }
175
176    fn compose(&self, ctx: &OperationContext, to: &str, args: OpCompose) -> Result<Self::Composer> {
177        self.0.compose(ctx, to, opcompose_with_mime(to, args))
178    }
179
180    async fn stat(&self, ctx: &OperationContext, path: &str, args: OpStat) -> Result<RpStat> {
181        self.0
182            .stat(ctx, path, args)
183            .await
184            .map(|rp| rpstat_with_mime(path, rp))
185    }
186
187    async fn rename(
188        &self,
189        ctx: &OperationContext,
190        from: &str,
191        to: &str,
192        args: OpRename,
193    ) -> Result<RpRename> {
194        self.0.rename(ctx, from, to, args).await
195    }
196
197    async fn restore(
198        &self,
199        ctx: &OperationContext,
200        path: &str,
201        args: OpRestore,
202    ) -> Result<RpRestore> {
203        self.0.restore(ctx, path, args).await
204    }
205
206    fn delete(&self, ctx: &OperationContext) -> Result<Self::Deleter> {
207        self.0.delete(ctx)
208    }
209
210    fn list(&self, ctx: &OperationContext, path: &str, args: OpList) -> Result<Self::Lister> {
211        self.0.list(ctx, path, args)
212    }
213
214    async fn presign(
215        &self,
216        ctx: &OperationContext,
217        path: &str,
218        args: OpPresign,
219    ) -> Result<RpPresign> {
220        self.0.presign(ctx, path, args).await
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use futures::TryStreamExt;
227
228    use super::*;
229
230    const DATA: &str = "<html>test</html>";
231    const CUSTOM: &str = "text/custom";
232    const HTML: &str = "text/html";
233
234    #[tokio::test]
235    async fn test_async() -> Result<()> {
236        let op = Operator::new(services::Memory::default())?.layer(MimeGuessLayer::new());
237
238        op.write("test0.html", DATA).await?;
239        assert_eq!(op.stat("test0.html").await?.content_type(), Some(HTML));
240
241        op.write("test1.asdfghjkl", DATA).await?;
242        assert_eq!(op.stat("test1.asdfghjkl").await?.content_type(), None);
243
244        op.write_with("test2.html", DATA)
245            .content_type(CUSTOM)
246            .await?;
247        assert_eq!(op.stat("test2.html").await?.content_type(), Some(CUSTOM));
248
249        let entries = op
250            .lister_with("")
251            .await?
252            .and_then(|entry| {
253                let op = op.clone();
254                async move { op.stat(entry.path()).await }
255            })
256            .try_collect::<Vec<_>>()
257            .await?;
258        assert_eq!(entries[0].content_type(), Some(HTML));
259        assert_eq!(entries[1].content_type(), None);
260        assert_eq!(entries[2].content_type(), Some(CUSTOM));
261
262        Ok(())
263    }
264}