[api-upload-refactor] Add iOS15 upload progress overload; toObjetct throws
- upload(..., onProgress: @Sendable (Double) -> Void) @available(iOS 15.0, *) via URLSession.upload(for:fromFile:delegate:) + UploadProgressDelegate; emits 1.0 on completion. Base upload stays iOS 13+ - move all upload code to LCEssentials+API+Upload.swift (buildUploadRequest / finishUpload / performUpload shared); API file 400 lines. Shared request helpers private -> internal - Dictionary.toObjetct: try! -> throws - APIUploadTests: +progress contract test (final 1.0, monotonic, decodes)
This commit is contained in:
193
Sources/LCEssentials/Classes/LCEssentials+API+Upload.swift
Normal file
193
Sources/LCEssentials/Classes/LCEssentials+API+Upload.swift
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2020 Loverde Co.
|
||||||
|
//
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
//
|
||||||
|
// The above copyright notice and this permission notice shall be included in
|
||||||
|
// all copies or substantial portions of the Software.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
// THE SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
#if os(iOS) || os(watchOS)
|
||||||
|
|
||||||
|
@available(iOS 13.0.0, *)
|
||||||
|
public extension API {
|
||||||
|
|
||||||
|
/// Uploads a `multipart/form-data` body and decodes the JSON response.
|
||||||
|
///
|
||||||
|
/// The body is serialised to a temporary file and streamed from disk, so a
|
||||||
|
/// large file never becomes fully resident in memory. The temp file is
|
||||||
|
/// always removed before returning.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - url: The URL string. `{name}` placeholders are filled from `pathParams`.
|
||||||
|
/// - method: The HTTP method. Defaults to `.post`.
|
||||||
|
/// - form: The multipart body (see ``MultipartForm``).
|
||||||
|
/// - pathParams: Values substituted into `{name}` placeholders in `url`.
|
||||||
|
/// - headers: Custom headers, merged over the defaults (custom wins). The
|
||||||
|
/// `Content-Type` is always set to the multipart type.
|
||||||
|
/// - debug: Print request/response debug logs. Defaults to `true`.
|
||||||
|
/// - timeoutInterval: Request timeout in seconds. Defaults to `120`.
|
||||||
|
/// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`.
|
||||||
|
/// - Returns: `T` decoded from the response body.
|
||||||
|
/// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP
|
||||||
|
/// status for non-2xx responses, or `DecodingError` on a malformed body.
|
||||||
|
func upload<T: Decodable & Sendable>(
|
||||||
|
url: String,
|
||||||
|
method: httpMethod = .post,
|
||||||
|
form: MultipartForm,
|
||||||
|
pathParams: [String: String] = [:],
|
||||||
|
headers: [String: String] = [:],
|
||||||
|
debug: Bool = true,
|
||||||
|
timeoutInterval: TimeInterval = 120,
|
||||||
|
networkServiceType: URLRequest.NetworkServiceType = .default
|
||||||
|
) async throws -> T {
|
||||||
|
let prepared = try buildUploadRequest(url: url, method: method, form: form,
|
||||||
|
pathParams: pathParams, headers: headers,
|
||||||
|
timeout: timeoutInterval, serviceType: networkServiceType)
|
||||||
|
defer { try? FileManager.default.removeItem(at: prepared.bodyFile) }
|
||||||
|
|
||||||
|
if debug { API.requestLOG(method: method, request: prepared.request) }
|
||||||
|
let (session, mustInvalidate) = makeSession()
|
||||||
|
defer { if mustInvalidate { session.finishTasksAndInvalidate() } }
|
||||||
|
|
||||||
|
let (data, response) = try await API.performUpload(prepared.request,
|
||||||
|
fromFile: prepared.bodyFile,
|
||||||
|
session: session)
|
||||||
|
return try API.finishUpload(data: data, response: response,
|
||||||
|
method: method, request: prepared.request, debug: debug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multipart upload that reports progress.
|
||||||
|
///
|
||||||
|
/// Same as ``upload(url:method:form:pathParams:headers:debug:timeoutInterval:networkServiceType:)``
|
||||||
|
/// but calls `onProgress` with a fraction in `0.0...1.0` as bytes are sent,
|
||||||
|
/// then `1.0` once the body has been fully transmitted.
|
||||||
|
///
|
||||||
|
/// - Parameter onProgress: invoked on an arbitrary queue; hop to the main
|
||||||
|
/// actor yourself before touching UI.
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
func upload<T: Decodable & Sendable>(
|
||||||
|
url: String,
|
||||||
|
method: httpMethod = .post,
|
||||||
|
form: MultipartForm,
|
||||||
|
pathParams: [String: String] = [:],
|
||||||
|
headers: [String: String] = [:],
|
||||||
|
debug: Bool = true,
|
||||||
|
timeoutInterval: TimeInterval = 120,
|
||||||
|
networkServiceType: URLRequest.NetworkServiceType = .default,
|
||||||
|
onProgress: @escaping @Sendable (Double) -> Void
|
||||||
|
) async throws -> T {
|
||||||
|
let prepared = try buildUploadRequest(url: url, method: method, form: form,
|
||||||
|
pathParams: pathParams, headers: headers,
|
||||||
|
timeout: timeoutInterval, serviceType: networkServiceType)
|
||||||
|
defer { try? FileManager.default.removeItem(at: prepared.bodyFile) }
|
||||||
|
|
||||||
|
if debug { API.requestLOG(method: method, request: prepared.request) }
|
||||||
|
let (session, mustInvalidate) = makeSession()
|
||||||
|
defer { if mustInvalidate { session.finishTasksAndInvalidate() } }
|
||||||
|
|
||||||
|
let progressDelegate = UploadProgressDelegate(onProgress: onProgress)
|
||||||
|
let (data, response) = try await session.upload(for: prepared.request,
|
||||||
|
fromFile: prepared.bodyFile,
|
||||||
|
delegate: progressDelegate)
|
||||||
|
onProgress(1.0)
|
||||||
|
return try API.finishUpload(data: data, response: response,
|
||||||
|
method: method, request: prepared.request, debug: debug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared internals
|
||||||
|
|
||||||
|
@available(iOS 13.0.0, *)
|
||||||
|
extension API {
|
||||||
|
|
||||||
|
private func buildUploadRequest(
|
||||||
|
url: String,
|
||||||
|
method: httpMethod,
|
||||||
|
form: MultipartForm,
|
||||||
|
pathParams: [String: String],
|
||||||
|
headers: [String: String],
|
||||||
|
timeout: TimeInterval,
|
||||||
|
serviceType: URLRequest.NetworkServiceType
|
||||||
|
) throws -> (request: URLRequest, bodyFile: URL) {
|
||||||
|
let resolvedURL = try makeURL(url, pathParams: pathParams)
|
||||||
|
var request = buildRequest(url: resolvedURL, method: method, headers: headers,
|
||||||
|
timeout: timeout, serviceType: serviceType)
|
||||||
|
let serialized = try form.serialize()
|
||||||
|
request.setValue(serialized.contentType, forHTTPHeaderField: "Content-Type")
|
||||||
|
return (request, serialized.fileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate static func finishUpload<T: Decodable & Sendable>(
|
||||||
|
data: Data,
|
||||||
|
response: URLResponse,
|
||||||
|
method: httpMethod,
|
||||||
|
request: URLRequest,
|
||||||
|
debug: Bool
|
||||||
|
) throws -> T {
|
||||||
|
let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE
|
||||||
|
switch classify(code: code, data: data, method: method, request: request, debug: debug) {
|
||||||
|
case .success:
|
||||||
|
return try decodeResponse(data)
|
||||||
|
case .clientError, .otherError:
|
||||||
|
throw friendlyError(code: code, data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridges `URLSession.uploadTask(with:fromFile:)` to `async` so the base
|
||||||
|
/// `upload` works down to iOS 13 (`URLSession.upload(for:fromFile:)` is iOS 15+).
|
||||||
|
fileprivate static func performUpload(_ request: URLRequest,
|
||||||
|
fromFile fileURL: URL,
|
||||||
|
session: URLSession) async throws -> (Data, URLResponse) {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
let task = session.uploadTask(with: request, fromFile: fileURL) { data, response, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
} else if let data, let response {
|
||||||
|
continuation.resume(returning: (data, response))
|
||||||
|
} else {
|
||||||
|
continuation.resume(throwing: API.defaultError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forwards `URLSession` upload progress to a `@Sendable` closure. Immutable
|
||||||
|
/// after `init`, safe to hand to `URLSession` as a task delegate.
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||||
|
|
||||||
|
private let onProgress: @Sendable (Double) -> Void
|
||||||
|
|
||||||
|
init(onProgress: @escaping @Sendable (Double) -> Void) {
|
||||||
|
self.onProgress = onProgress
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(_ session: URLSession,
|
||||||
|
task: URLSessionTask,
|
||||||
|
didSendBodyData bytesSent: Int64,
|
||||||
|
totalBytesSent: Int64,
|
||||||
|
totalBytesExpectedToSend: Int64) {
|
||||||
|
guard totalBytesExpectedToSend > 0 else { return }
|
||||||
|
let fraction = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
|
||||||
|
onProgress(min(max(fraction, 0), 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -175,12 +175,12 @@ public actor API {
|
|||||||
|
|
||||||
// MARK: - Request building
|
// MARK: - Request building
|
||||||
|
|
||||||
private func makeURL(_ template: String, pathParams: [String: String]) throws -> URL {
|
func makeURL(_ template: String, pathParams: [String: String]) throws -> URL {
|
||||||
guard let url = URL(string: template.replaceURL(pathParams)) else { throw API.defaultError }
|
guard let url = URL(string: template.replaceURL(pathParams)) else { throw API.defaultError }
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildRequest(url: URL,
|
func buildRequest(url: URL,
|
||||||
method: httpMethod,
|
method: httpMethod,
|
||||||
headers: [String: String],
|
headers: [String: String],
|
||||||
timeout: TimeInterval,
|
timeout: TimeInterval,
|
||||||
@@ -205,9 +205,9 @@ public actor API {
|
|||||||
|
|
||||||
// MARK: - Response handling
|
// MARK: - Response handling
|
||||||
|
|
||||||
private enum ResponseDisposition { case success, clientError, otherError }
|
enum ResponseDisposition { case success, clientError, otherError }
|
||||||
|
|
||||||
private static func classify(code: Int, data: Data, method: httpMethod,
|
static func classify(code: Int, data: Data, method: httpMethod,
|
||||||
request: URLRequest, debug: Bool) -> ResponseDisposition {
|
request: URLRequest, debug: Bool) -> ResponseDisposition {
|
||||||
let error = (200..<300).contains(code) ? nil : URLError(URLError.Code(rawValue: code))
|
let error = (200..<300).contains(code) ? nil : URLError(URLError.Code(rawValue: code))
|
||||||
if debug {
|
if debug {
|
||||||
@@ -220,7 +220,7 @@ public actor API {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decodeResponse<T: Decodable & Sendable>(_ data: Data) throws -> T {
|
static func decodeResponse<T: Decodable & Sendable>(_ data: Data) throws -> T {
|
||||||
if T.self == String.self, let string = String(data: data, encoding: .utf8) as? T {
|
if T.self == String.self, let string = String(data: data, encoding: .utf8) as? T {
|
||||||
return string
|
return string
|
||||||
}
|
}
|
||||||
@@ -232,7 +232,7 @@ public actor API {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func friendlyError(code: Int, data: Data) -> NSError {
|
static func friendlyError(code: Int, data: Data) -> NSError {
|
||||||
let urlError = URLError(URLError.Code(rawValue: code))
|
let urlError = URLError(URLError.Code(rawValue: code))
|
||||||
return NSError.createErrorWith(code: code,
|
return NSError.createErrorWith(code: code,
|
||||||
description: urlError.localizedDescription,
|
description: urlError.localizedDescription,
|
||||||
@@ -258,7 +258,7 @@ public actor API {
|
|||||||
/// - The common no-cert case reuses `URLSession.shared`.
|
/// - The common no-cert case reuses `URLSession.shared`.
|
||||||
///
|
///
|
||||||
/// - Returns: the session and whether the caller must invalidate it.
|
/// - Returns: the session and whether the caller must invalidate it.
|
||||||
private func makeSession() -> (session: URLSession, mustInvalidate: Bool) {
|
func makeSession() -> (session: URLSession, mustInvalidate: Bool) {
|
||||||
#if canImport(Security)
|
#if canImport(Security)
|
||||||
let delegate: URLSessionDelegateHandler? = certData != nil
|
let delegate: URLSessionDelegateHandler? = certData != nil
|
||||||
? URLSessionDelegateHandler(certData: certData, password: certPassword)
|
? URLSessionDelegateHandler(certData: certData, password: certPassword)
|
||||||
@@ -276,79 +276,6 @@ public actor API {
|
|||||||
return (URLSession.shared, false)
|
return (URLSession.shared, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Upload
|
|
||||||
|
|
||||||
/// Uploads a `multipart/form-data` body and decodes the JSON response.
|
|
||||||
///
|
|
||||||
/// The body is serialised to a temporary file and streamed from disk, so a
|
|
||||||
/// large file never becomes fully resident in memory. The temp file is
|
|
||||||
/// always removed before returning.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - url: The URL string. `{name}` placeholders are filled from `pathParams`.
|
|
||||||
/// - method: The HTTP method. Defaults to `.post`.
|
|
||||||
/// - form: The multipart body (see ``MultipartForm``).
|
|
||||||
/// - pathParams: Values substituted into `{name}` placeholders in `url`.
|
|
||||||
/// - headers: Custom headers, merged over the defaults (custom wins). The
|
|
||||||
/// `Content-Type` is always set to the multipart type.
|
|
||||||
/// - debug: Print request/response debug logs. Defaults to `true`.
|
|
||||||
/// - timeoutInterval: Request timeout in seconds. Defaults to `120`.
|
|
||||||
/// - networkServiceType: `URLRequest.NetworkServiceType`. Defaults to `.default`.
|
|
||||||
/// - Returns: `T` decoded from the response body.
|
|
||||||
/// - Throws: `URLError` for transport failures, an `NSError` carrying the HTTP
|
|
||||||
/// status for non-2xx responses, or `DecodingError` on a malformed body.
|
|
||||||
public func upload<T: Decodable & Sendable>(
|
|
||||||
url: String,
|
|
||||||
method: httpMethod = .post,
|
|
||||||
form: MultipartForm,
|
|
||||||
pathParams: [String: String] = [:],
|
|
||||||
headers: [String: String] = [:],
|
|
||||||
debug: Bool = true,
|
|
||||||
timeoutInterval: TimeInterval = 120,
|
|
||||||
networkServiceType: URLRequest.NetworkServiceType = .default
|
|
||||||
) async throws -> T {
|
|
||||||
let resolvedURL = try makeURL(url, pathParams: pathParams)
|
|
||||||
var urlRequest = buildRequest(url: resolvedURL, method: method, headers: headers,
|
|
||||||
timeout: timeoutInterval, serviceType: networkServiceType)
|
|
||||||
|
|
||||||
let serialized = try form.serialize()
|
|
||||||
defer { try? FileManager.default.removeItem(at: serialized.fileURL) }
|
|
||||||
urlRequest.setValue(serialized.contentType, forHTTPHeaderField: "Content-Type")
|
|
||||||
|
|
||||||
if debug { API.requestLOG(method: method, request: urlRequest) }
|
|
||||||
let (session, mustInvalidate) = makeSession()
|
|
||||||
defer { if mustInvalidate { session.finishTasksAndInvalidate() } }
|
|
||||||
|
|
||||||
let (data, response) = try await Self.performUpload(urlRequest,
|
|
||||||
fromFile: serialized.fileURL,
|
|
||||||
session: session)
|
|
||||||
let code = (response as? HTTPURLResponse)?.statusCode ?? LCEssentials.DEFAULT_ERROR_CODE
|
|
||||||
switch Self.classify(code: code, data: data, method: method, request: urlRequest, debug: debug) {
|
|
||||||
case .success:
|
|
||||||
return try Self.decodeResponse(data)
|
|
||||||
case .clientError, .otherError:
|
|
||||||
throw Self.friendlyError(code: code, data: data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bridges `URLSession.uploadTask(with:fromFile:)` to `async` so the upload
|
|
||||||
/// works down to iOS 13 (`URLSession.upload(for:fromFile:)` is iOS 15+).
|
|
||||||
private static func performUpload(_ request: URLRequest,
|
|
||||||
fromFile fileURL: URL,
|
|
||||||
session: URLSession) async throws -> (Data, URLResponse) {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
let task = session.uploadTask(with: request, fromFile: fileURL) { data, response, error in
|
|
||||||
if let error {
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
} else if let data, let response {
|
|
||||||
continuation.resume(returning: (data, response))
|
|
||||||
} else {
|
|
||||||
continuation.resume(throwing: API.defaultError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
task.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(Security)
|
#if canImport(Security)
|
||||||
|
|||||||
@@ -137,11 +137,10 @@ public extension Dictionary {
|
|||||||
|
|
||||||
/// - LoverdeCo: Convert Dictonary to Object
|
/// - LoverdeCo: Convert Dictonary to Object
|
||||||
///
|
///
|
||||||
/// - returns: Object: Codable/Decodable
|
/// - returns: Object: Decodable & Sendable
|
||||||
func toObjetct<T: Codable & Sendable>() -> T {
|
/// - throws: `DecodingError` when the dictionary does not match `T`.
|
||||||
let jsonString = self.convertToJSON
|
func toObjetct<T: Codable & Sendable>() throws -> T {
|
||||||
let output: T = try! JSONDecoder.decode(jsonString)
|
try JSONDecoder.decode(self.convertToJSON)
|
||||||
return output
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if key exists in dictionary.
|
/// Check if key exists in dictionary.
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ private struct UploadEcho: Decodable, Sendable, Equatable {
|
|||||||
let ok: Bool
|
let ok: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Thread-safe sink for progress callbacks (invoked on an arbitrary queue).
|
||||||
|
private final class ProgressBox: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var storage: [Double] = []
|
||||||
|
func record(_ value: Double) { lock.lock(); storage.append(value); lock.unlock() }
|
||||||
|
var values: [Double] { lock.lock(); defer { lock.unlock() }; return storage }
|
||||||
|
}
|
||||||
|
|
||||||
final class APIUploadTests: XCTestCase {
|
final class APIUploadTests: XCTestCase {
|
||||||
|
|
||||||
private var api: API!
|
private var api: API!
|
||||||
@@ -79,6 +87,27 @@ final class APIUploadTests: XCTestCase {
|
|||||||
XCTAssertEqual(tempCountInDir(dir), before)
|
XCTAssertEqual(tempCountInDir(dir), before)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(iOS 15.0, *)
|
||||||
|
func testProgressOverloadDeliversFinalCompletionAndDecodes() async throws {
|
||||||
|
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"ok":true}"#.utf8)))
|
||||||
|
|
||||||
|
var form = MultipartForm()
|
||||||
|
form.file("f", data: Data(repeating: 0x41, count: 4096), filename: "a.bin")
|
||||||
|
|
||||||
|
let progressBox = ProgressBox()
|
||||||
|
let result: UploadEcho = try await api.upload(
|
||||||
|
url: "https://api.example.com/x",
|
||||||
|
form: form,
|
||||||
|
onProgress: { progressBox.record($0) }
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(result, UploadEcho(ok: true))
|
||||||
|
let values = progressBox.values
|
||||||
|
XCTAssertEqual(values.last, 1.0, "final progress must be 1.0")
|
||||||
|
XCTAssertTrue(values.allSatisfy { $0 >= 0 && $0 <= 1 })
|
||||||
|
XCTAssertEqual(values, values.sorted(), "progress must be monotonic non-decreasing")
|
||||||
|
}
|
||||||
|
|
||||||
func testUploadServerErrorThrowsWithStatus() async {
|
func testUploadServerErrorThrowsWithStatus() async {
|
||||||
StubURLProtocol.setStub(.init(statusCode: 413, body: Data(#"{"error":"too big"}"#.utf8)))
|
StubURLProtocol.setStub(.init(statusCode: 413, body: Data(#"{"error":"too big"}"#.utf8)))
|
||||||
var form = MultipartForm()
|
var form = MultipartForm()
|
||||||
|
|||||||
Reference in New Issue
Block a user