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.with_content_type(mime);
100    }
101
102    op
103}
104
105fn rpstat_with_mime(path: &str, rp: RpStat) -> RpStat {
106    rp.map_metadata(|metadata| {
107        if metadata.content_type().is_some() {
108            return metadata;
109        }
110
111        if let Some(mime) = mime_from_path(path) {
112            return metadata.with_content_type(mime.into());
113        }
114
115        metadata
116    })
117}
118
119impl Service for MimeGuessAccessor {
120    type Reader = oio::Reader;
121    type Writer = oio::Writer;
122    type Lister = oio::Lister;
123    type Deleter = oio::Deleter;
124    type Copier = oio::Copier;
125
126    fn info(&self) -> ServiceInfo {
127        self.0.info()
128    }
129
130    fn capability(&self) -> Capability {
131        self.0.capability()
132    }
133
134    async fn create_dir(
135        &self,
136        ctx: &OperationContext,
137        path: &str,
138        args: OpCreateDir,
139    ) -> Result<RpCreateDir> {
140        self.0.create_dir(ctx, path, args).await
141    }
142
143    fn read(&self, ctx: &OperationContext, path: &str, args: OpRead) -> Result<Self::Reader> {
144        self.0.read(ctx, path, args)
145    }
146
147    fn write(&self, ctx: &OperationContext, path: &str, args: OpWrite) -> Result<Self::Writer> {
148        self.0.write(ctx, path, opwrite_with_mime(path, args))
149    }
150
151    fn copy(
152        &self,
153        ctx: &OperationContext,
154        from: &str,
155        to: &str,
156        args: OpCopy,
157        opts: OpCopier,
158    ) -> Result<Self::Copier> {
159        self.0.copy(ctx, from, to, args, opts)
160    }
161
162    async fn stat(&self, ctx: &OperationContext, path: &str, args: OpStat) -> Result<RpStat> {
163        self.0
164            .stat(ctx, path, args)
165            .await
166            .map(|rp| rpstat_with_mime(path, rp))
167    }
168
169    async fn rename(
170        &self,
171        ctx: &OperationContext,
172        from: &str,
173        to: &str,
174        args: OpRename,
175    ) -> Result<RpRename> {
176        self.0.rename(ctx, from, to, args).await
177    }
178
179    fn delete(&self, ctx: &OperationContext) -> Result<Self::Deleter> {
180        self.0.delete(ctx)
181    }
182
183    fn list(&self, ctx: &OperationContext, path: &str, args: OpList) -> Result<Self::Lister> {
184        self.0.list(ctx, path, args)
185    }
186
187    async fn presign(
188        &self,
189        ctx: &OperationContext,
190        path: &str,
191        args: OpPresign,
192    ) -> Result<RpPresign> {
193        self.0.presign(ctx, path, args).await
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use futures::TryStreamExt;
200
201    use super::*;
202
203    const DATA: &str = "<html>test</html>";
204    const CUSTOM: &str = "text/custom";
205    const HTML: &str = "text/html";
206
207    #[tokio::test]
208    async fn test_async() -> Result<()> {
209        let op = Operator::new(services::Memory::default())?.layer(MimeGuessLayer::new());
210
211        op.write("test0.html", DATA).await?;
212        assert_eq!(op.stat("test0.html").await?.content_type(), Some(HTML));
213
214        op.write("test1.asdfghjkl", DATA).await?;
215        assert_eq!(op.stat("test1.asdfghjkl").await?.content_type(), None);
216
217        op.write_with("test2.html", DATA)
218            .content_type(CUSTOM)
219            .await?;
220        assert_eq!(op.stat("test2.html").await?.content_type(), Some(CUSTOM));
221
222        let entries = op
223            .lister_with("")
224            .await?
225            .and_then(|entry| {
226                let op = op.clone();
227                async move { op.stat(entry.path()).await }
228            })
229            .try_collect::<Vec<_>>()
230            .await?;
231        assert_eq!(entries[0].content_type(), Some(HTML));
232        assert_eq!(entries[1].content_type(), None);
233        assert_eq!(entries[2].content_type(), Some(CUSTOM));
234
235        Ok(())
236    }
237}